Using jsonb for Flexible Data
Learn to use PostgreSQL's jsonb data type for flexible, schema-less data models. This lesson explains when and how to choose jsonb over traditional relational columns, with a hands-on example, performance considerations, and common pitfalls.
Focus: use jsonb for flexible data models
You've modeled your data with rigid columns, and now a new product requirement arrives — you need to store a field that changes shape between customers, events, or API calls. Migrating the schema, backfilling rows, and coordinating releases across services can add days to an otherwise simple feature. PostgreSQL's jsonb data type cuts through this: store arbitrary, well-typed JSON directly in a column, query deep into it with SQL, and index what matters. This lesson teaches you to use jsonb for flexible data models without losing PostgreSQL's power, so you can adapt to changing requirements without a migration every time.
The problem this lesson solves
Traditional relational modeling requires a fixed shape. When your data doesn't fit a neat grid — be it user preferences, event payloads, or integration metadata — you're forced into awkward designs: wide tables with hundreds of mostly null columns, entity-attribute-value (EAV) schemas that are painful to query, or serializing blobs that are opaque to the database.
Here's what happens when you avoid flexibility:
- Schema migrations become frequent and risky. Every new optional field means an
ALTER TABLEand a backfill. - Queries get slow or ugly. With EAV, you join multiple times just to fetch one record's attributes.
- Code and schema drift. Your application accepts JSON, but the database forces you to shred it into columns.
jsonb changes the trade-off. It gives you a schema-less container inside a relational database. You keep ACID compliance, SQL joins, and vacuuming, but you gain the ability to store nested, varying data and query it with rich operators such as ->, ->>, and @>.
By the end of this lesson, you'll understand the core idea behind using jsonb for flexible data models, and you'll complete a practical exercise that you can apply immediately.
Core concept / mental model
Think of a normal table as a spreadsheet — every row has the same columns. Think of jsonb as a document — each row can contain a nested, self-describing structure. The magic is that the document lives inside a spreadsheet cell.
With jsonb, you model the intersection of two worlds:
- Relational for what's stable and query-hungry (ID, timestamp, owner).
- Flexible for what varies or evolves (settings, metadata, external API responses).
Here's a simple mental image:
+------------------+--------------------------------------------------+
| product_id (int)| attributes (jsonb) |
+------------------+--------------------------------------------------+
| 1 | {"color": "red", "size": "M", "tags": ["sale"]} |
| 2 | {"weight": 1.5, "country": "DE", "warranty": 2} |
+------------------+--------------------------------------------------+
The attributes column has no predefined keys — each row can hold a different structure. But you can still index into it, filter on it, and even create a GIN index for fast containment searches.
Why jsonb (not json)?
PostgreSQL offers two JSON types. The difference is critical:
| Type | Storage | Unique keys | Indexing | Typical use |
|---|---|---|---|---|
json |
Text (exact binary copy) | Preserved | No native index support | Logging exact payloads, fast ingest |
jsonb |
Binary (decomposed) | Last one wins | GIN indexes for @>, ?, ?|, ?& |
Querying, indexing, modifying |
Pro tip: Always choose
jsonbunless you need to preserve key order or exact whitespace from the original JSON document.jsonbnormalizes and reorders keys, which usually doesn't matter for application logic.
How it works step by step
Let's break down how to use jsonb effectively, from column creation to querying.
1. Create a table with a jsonb column
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_type TEXT NOT NULL,
payload JSONB NOT NULL
);
The payload column can hold any valid JSON — an array, an object, a scalar. No constraints on shape unless you add a CHECK constraint.
2. Insert JSON data
You can insert a string literal, but you'll often pass a parameter from your application. Always use jsonb type in your driver (e.g., psycopg2 will adapt dictionaries to jsonb by default, but be explicit to avoid surprises).
INSERT INTO events (event_type, payload) VALUES
('page_view', '{"page": "/home", "user_id": 42, "utm": {"source": "newsletter"}}'),
('purchase', '{"items": [{"sku": "A1", "qty": 2}], "total": 49.90}');
If you have a JSON string in a regular text column, you can cast: payload::jsonb.
3. Query into the JSON
Use the arrow operators:
->returns a JSONB value (works with objects and arrays).->>returns as text.
SELECT event_type,
payload->>'page' AS page,
payload->'utm'->>'source' AS utm_source
FROM events
WHERE event_type = 'page_view';
Expected output:
event_type | page | utm_source
------------+---------+------------
page_view | /home | newsletter
4. Filter on nested values
SELECT id
FROM events
WHERE payload @> '{"utm": {"source": "newsletter"}}';
The @> operator checks if the left jsonb contains the right — useful for exact subdocument matching.
5. Index for speed
Without an index, a @> query scans the whole table. Create a GIN index to make it fast:
CREATE INDEX idx_events_payload_gin ON events USING gin (payload);
Now containment queries (@>, ?, ?|, ?&) and existence queries (?) can use the index.
Hands-on walkthrough
Let's build a small example from scratch. We'll create a product_attributes table for an e-commerce platform where each product type has different fields.
-- Create the table
CREATE TABLE product (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- Insert varied rows
INSERT INTO product (name, attributes) VALUES
('Running Shoes', '{"color": "blue", "sizes": [9, 10, 11], "material": "mesh"}'),
('Wireless Mouse', '{"dpi": 1600, "buttons": 5, "wireless": true}'),
('Yoga Mat', '{"thickness_mm": 6, "non_slip": true, "carry_strap": false}');
-- Query products with a 'wireless' key set to true
SELECT name
FROM product
WHERE attributes @> '{"wireless": true}';
-- Extract nested text
SELECT name, attributes->>'color' AS color
FROM product
WHERE attributes ? 'color';
-- Update one attribute without touching others
UPDATE product
SET attributes = jsonb_set(attributes, '{material}', '"knit"')
WHERE name = 'Running Shoes';
Expected output for the SELECTs:
name
------------
Wireless Mouse
name | color
--------------+-------
Running Shoes| blue
The jsonb_set function is your friend for targeted updates — it avoids rewriting the whole document from your application code.
Working with arrays and existence
Sometimes you need to check if a key exists, or search inside an array of objects. Here's how:
-- Does any product have a "sizes" key?
SELECT name FROM product WHERE attributes ? 'sizes';
-- Select products where the sizes array contains 10
SELECT name
FROM product
WHERE attributes->'sizes' @> '10';
You can also unnest JSON arrays into rows for aggregation:
SELECT p.name, size
FROM product p, jsonb_array_elements_text(p.attributes->'sizes') AS size;
Compare options / when to choose what
You don't need jsonb for everything. Here's a comparison of the common approaches for flexible data:
| Approach | Best for | Trade-offs |
|---|---|---|
| Traditional columns | Stable schema, heavy querying, strong typing | Rigid, needs migration for each new field |
| jsonb column | Varying attributes, fast iteration, self-contained documents | Less type safety, harder to enforce constraints, queries can be less obvious |
| EAV (table per key) | Extremely sparse data, when you need to index individual keys | Complex queries, many joins, poor performance at scale |
| Full document store (e.g., MongoDB) | Pure document workloads, no relational needs | Zero join support, no native transactions across collections |
Practical rule of thumb:
- If the attribute is always present and you query it frequently, make it a real column.
- If the attribute is optional, varies by type, or comes from an external API, put it in
jsonb. - If you need a constraint, you can add a
CHECKon a jsonb expression, but it's not as natural as a column constraint.
Common variations
jsonbwith a generated column: You can create a generated column that extracts a common field, then index that column for fast filters.jsonbwith a check constraint: Enforce that certain keys exist or have a specific type.- Using
jsonbin a normalized way: Keep stable attributes in columns, and add ajsonbcolumn for the 'tail' of varying fields.
All these are valid, but the last one is probably the most balanced for production apps.
Troubleshooting & edge cases
1. jsonb key order and duplicate keys
jsonb does not preserve key order, and duplicate keys collapse to the last one. If you need exact fidelity, use json (not jsonb), or normalise your input.
2. Query that returns NULL unexpectedly
If payload->>'page' returns NULL, the key may not exist, or it exists with the value null. Use ? to test existence first:
SELECT event_type,
CASE WHEN payload ? 'page' THEN payload->>'page' ELSE 'missing' END
FROM events;
3. Index not used
If your query uses payload->>'color' = 'blue', a GIN index won't help unless you create an expression index:
CREATE INDEX idx_product_color ON product ((attributes->>'color'));
Use EXPLAIN to confirm the index is being used.
4. JSON number precision
jsonb stores numbers as numeric, but if you cast to integer or float, you can lose precision. Be explicit:
SELECT (payload->>'total')::numeric FROM events;
5. Casting between json and jsonb
Casting json to jsonb loses key order and duplicate keys — plan your exports accordingly.
What you learned & what's next
You now understand the problem of rigid schemas, the core idea of using jsonb for flexible data models, and you've completed a hands-on exercise that shows inserting, querying, updating, and indexing JSON documents. You also learned when to choose jsonb over other approaches and how to avoid the most common pitfalls.
Next lesson: You'll learn how to combine JSON with relational data — using jsonb to store related records and how to handle transactional consistency when mixing the two. That will complete your toolkit for pragmatic schema design.
Keys takeaways to remember:
jsonboffers flexible, queryable JSON storage inside a relational engine — perfect for evolving schemas.- Use GIN indexes for containment
@>queries, and expression indexes for frequent key extraction. - Update specific keys with
jsonb_setrather than rewriting the whole document. - Keep stable attributes as columns and varying attributes in
jsonbfor optimal performance and maintainability.
Practice recap
Create a user_prefs table with a prefs jsonb column and insert 3 rows with different structures. Write a query that finds all users with dark_mode: true. Then, add a GIN index and run EXPLAIN to see it used. Finally, update one user's notifications.email to false using jsonb_set.
Common mistakes
- Using
jsoninstead ofjsonbwhen you need to query or index the JSON —jsonhas no native index support and is slower for operations. - Not creating a GIN index on the
jsonbcolumn, then wondering why containment queries are slow. - Trying to enforce a strict schema with
jsonbby adding many CHECK constraints — this defeats the purpose; keep it flexible. - Forgetting that
jsonbreorders keys and removes duplicates — if you need exact input, usejson. - Extracting a key with
->>and comparing to a number without a cast, e.g.,payload->>'price' > 10fails because it's text comparison.
Variations
- Use a generated column to extract a common jsonb key into a regular column, then index that column for even faster filters.
- Combine
jsonbwith a partial index to index only rows with a specific key or value, reducing index size. - Use the
jsonb_path_opsoperator class for GIN indexes when you mostly do@>containment queries — faster and smaller than the default.
Real-world use cases
- Storing user preferences or feature flags where each user may have different settings, without migrating the table for each new option.
- Logging external API responses (e.g., webhooks) as jsonb so you can ingest new payload shapes without schema changes and query them later.
- Product catalogs with varying attributes per category: store common columns (name, price) and a jsonb
attributescolumn for per-item specifics.
Key takeaways
jsonbstores JSON in binary, queryable form — choose it overjsonfor anything you'll query or index.- Use
@>for containment checks and?for key existence; GIN indexes speed up both. - Update single keys with
jsonb_setto avoid rewriting the entire document. - Keep stable fields as columns, varying fields in jsonb — that's the balanced approach.
- Always cast extracted values to the correct type when doing comparisons.
- Add an expression index on
(attributes->>'key')for queries that filter by a specific jsonb key.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.