The whole course has assumed you know which columns you need. And that's nearly always true: an order has a customer, a date, a status and shipping costs, and that doesn't change. But some data has no fixed shape. An olive oil has acidity, olive variety and extraction method; a cream has ingredients, skin type and volume; a bamboo toothbrush has none of those things and does have a bristle type. Adding a column per attribute would give you an eighty-column table that's almost entirely null, and building a key-value pair table —the classic EAV— turns any query into a jigsaw of self-joins.

That's what PostgreSQL has JSONB for: a column storing a document with its own structure, indexable and queryable with SQL. This is where 08-03's promise (GIN indexes over JSON) gets kept, and it covers the case of an API response, user preferences or an event log. You'll see how to build documents, how to read them, how to modify them, how to turn them back into rows so you can keep using all the SQL in this course, and —the most important part of the lesson— what should never go inside a JSON.

Contents

  1. When a fixed schema isn't enough
  2. JSON versus JSONB
  3. Building documents
  4. GreenStore's products.attributes column
  5. Accessing: ->, ->>, #>, #>>
  6. Searching: containment, existence and JSONPath
  7. Modifying documents
  8. Expanding to rows: the bridge back to SQL
  9. Indexing with GIN
  10. The design discussion: what does NOT go in JSON
  11. Common Mistakes and Tips
  12. Exercises
  13. Module conclusion

  1. When a fixed schema isn't enough

Four situations in which the pure relational model gets uncomfortable:

Situation Why it hurts in columns GreenStore example
Attributes varying by type of item One column per attribute, almost all null The oil's acidity, the cream's ingredients, organic certifications
Responses from an external API Somebody else decides their shape, and it changes without warning Whatever the payment gateway returns
Preferences and events Every user and every event type carries different data Language, notifications; clicks, errors, traces

And the three classic ways out, with their price:

Approach Advantage Cost
One column per attribute Typed, constraints, cheap indexes A sparse table; every new attribute is an ALTER TABLE (05-06)
EAV table (attribute_id, value) Flexible, no migrations Everything is text; a query on three attributes is three self-joins
A JSONB column Flexible and queryable, with indexes of its own No typing and no referential integrity; easy to misuse

  1. JSON versus JSONB

PostgreSQL has two types, and choosing the wrong one costs you.

JSON JSONB
How it's stored Literal text, exactly as you wrote it Decomposed binary (a tree of keys and values)
On write Only validates the syntax: very fast Parses and normalizes: a bit slower
On reading a key Reparses the whole text every time Direct access: much faster
Whitespace, formatting and key order Preserved Lost: reordered internally
Duplicate keys All kept Keeps the last one
@>, ?, @@ operators / GIN index No / no Yes / yes

The practical rule: use JSONB unless you need to preserve the exact text. And that "unless" is very narrow: basically, storing an external service's literal response because you have to verify a digital signature or reproduce it byte for byte. For everything else, JSONB. The difference shows up immediately:

SELECT '{"b": 1, "a": 2, "a": 3}'::json  AS as_json,
       '{"b": 1, "a": 2, "a": 3}'::jsonb AS as_jsonb;
as_json as_jsonb
{"b": 1, "a": 2, "a": 3} {"a": 3, "b": 1}

The json stores the nonsense exactly as it is —repeated a key included—; the jsonb normalizes, sorts and keeps the last value of a.

  1. Building documents

Function What it does Example
Literal Text with ::jsonb '{"origin": "Spain"}'::jsonb
to_jsonb(x) Converts any value or row to JSON to_jsonb(p.*) → the whole product as an object
jsonb_build_object(k, v, ...) An object from alternating keys and values jsonb_build_object('id', p.id, 'price', p.price)
jsonb_build_array(a, b, ...) An array from separate values jsonb_build_array('bio', 'vegan')
jsonb_agg(expr) Aggregate: gathers many rows into an array All the lines of an order
jsonb_object_agg(k, v) Aggregate: turns rows into key-value pairs {"Food": 256.27, ...}

The star case —returning a whole order with its lines nested in a single row, which is what an API needs— combines the last three:

SELECT jsonb_build_object(
         'order_id', o.id, 'date', o.order_date, 'shipping', o.shipping_cost,
         'customer', jsonb_build_object('id', c.id, 'country', c.country,
                                        'name', c.name || ' ' || c.last_name),
         'lines',   (SELECT jsonb_agg(jsonb_build_object(
                              'product', p.name, 'quantity', ol.quantity,
                              'amount',  ROUND(ol.quantity * ol.unit_price * (1 - ol.discount), 2))
                            ORDER BY ol.id)
                     FROM order_lines AS ol
                     JOIN products    AS p ON p.id = ol.product_id
                     WHERE ol.order_id = o.id)
       ) AS order_doc
FROM   orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE  o.id = 1;
{"date": "2025-03-04", "shipping": 4.95, "order_id": 1,
 "customer": {"id": 1, "country": "Spain", "name": "Lucía Martínez Soler"},
 "lines": [{"amount": 23.90, "quantity": 2, "product": "Extra virgin olive oil 500 ml"},
           {"amount": 11.70, "quantity": 3, "product": "Organic brown rice 1 kg"},
           {"amount": 6.50,  "quantity": 2, "product": "Organic chamomile tea 20 bags"}]}

One row, one column, order 1 complete with its €42.10 across three lines. Notice two things: jsonb_agg accepts its own ORDER BY inside the parentheses, and the keys come out in a different order from the one you wrote them in, because it's jsonb. This saves the application the work of reassembling flat rows into a nested object, and it's why many modern APIs return straight through whatever the database produces — the link to 11-05.

And for a compact summary, jsonb_object_agg:

-- Careful: an aggregate can't be nested inside another, so you group first (10-02)
WITH by_category AS (
    SELECT cat.name, ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total
    FROM   order_lines AS ol
    JOIN   products   AS p   ON p.id   = ol.product_id
    JOIN   categories AS cat ON cat.id = p.category_id
    GROUP  BY cat.name)
SELECT jsonb_object_agg(name, total) AS revenue FROM by_category;
{"Food": 256.27, "Drinks": 195.28, "Personal hygiene": 31.50,
 "Sustainable home": 88.58, "Natural cosmetics": 156.32}

The five categories with sales and their canonical figures, in a single value.

  1. GreenStore's products.attributes column

From here on we work with a new column. It isn't canonical: it's this lesson's example and no other module uses it.

-- ⚠️ NOT CANONICAL: example column from 10-06. Reload 01-06's script when you're done.
ALTER TABLE products ADD COLUMN attributes JSONB;

UPDATE products SET attributes = '{"origin":"Spain","acidity":0.3,"variety":"picual",
  "extraction":"cold","certifications":["eu-organic","gluten-free"]}'::jsonb WHERE id = 1;
UPDATE products SET attributes = '{"origin":"Spain","bean_type":"wholegrain","certifications":["eu-organic"]}' WHERE id = 2;
UPDATE products SET attributes = '{"origin":"France","volume_ml":50,"skin_type":"dry",
  "ingredients":["aloe vera","jojoba oil"],"certifications":["cosmos-organic","vegan"]}'::jsonb
  WHERE id = 6;
UPDATE products SET attributes = '{"origin":"Portugal","volume_ml":200,"skin_type":"normal",
  "ingredients":["sweet almond","vitamin E"],"certifications":["vegan"]}'::jsonb WHERE id = 8;
UPDATE products SET attributes = '{"origin":"Portugal","strength":"ceremonial","grams":30,"certifications":["eu-organic","vegan"]}' WHERE id = 15;
UPDATE products SET attributes = '{"origin":"Germany","material":"bamboo","bristles":"soft nylon","certifications":["vegan"]}' WHERE id = 18;

Six products with attributes; the other fourteen have attributes at NULL, which is normal for this kind of column and something the queries will have to take into account.

  1. Accessing: ->, ->>, #>, #>>

Four operators, and the difference between them is the number-one source of errors with JSON in PostgreSQL:

Operator Argument Returns Example on product 1
-> Key (text) or index (integer) jsonb attributes -> 'origin'"Spain" (with quotes)
->> Key or index text attributes ->> 'origin'Spain
#> Path: an array of text jsonb attributes #> '{certifications,0}'"eu-organic"
#>> Path text attributes #>> '{certifications,0}'eu-organic

The mnemonic: the double arrow >> pulls the value out "raw", as text. The single one still returns JSON, and that lets you chain: attributes -> 'certifications' ->> 0 steps down into the array with -> and pulls the first element out as text with ->>.

SELECT id, name,
       attributes ->  'origin'            AS origin_jsonb,
       attributes ->> 'origin'            AS origin_text,
       (attributes ->> 'volume_ml')::int  AS volume_ml,
       attributes -> 'certifications'     AS certifications,
       attributes #>> '{certifications,0}' AS first_cert
FROM   products
WHERE  attributes IS NOT NULL
ORDER  BY id;
id name origin_jsonb origin_text volume_ml certifications first_cert
1 Extra virgin olive oil 500 ml "Spain" Spain (null) ["eu-organic", "gluten-free"] eu-organic
2 Organic brown rice 1 kg "Spain" Spain (null) ["eu-organic"] eu-organic
6 Aloe vera face cream 50 ml "France" France 50 ["cosmos-organic", "vegan"] cosmos-organic
8 Almond body oil 200 ml "Portugal" Portugal 200 ["vegan"] vegan
15 Ceremonial matcha green tea 30 g "Portugal" Portugal (null) ["eu-organic", "vegan"] eu-organic
18 Bamboo toothbrush "Germany" Germany (null) ["vegan"] vegan

Three things to spot in that table. First: "Spain" with quotes isn't Spain; comparing attributes -> 'origin' = 'Spain' fails because there's a jsonb on the left and a text on the right — you have to use ->>, or compare against '"Spain"'::jsonb. Second: everything coming out of ->> is text and has to be cast (::int, ::numeric) to compare as a number; otherwise '200' < '50' is true because it compares alphabetically. Third: a key that doesn't exist returns NULL, not an error — convenient, and dangerous, because a mistyped key doesn't complain.

  1. Searching: containment, existence and JSONPath

The previous operators extract; these filter, and they're the ones that make use of the GIN index.

Operator Reads as "…" Example
@> contains attributes @> '{"origin":"Spain"}'
<@ is contained in '{"origin":"Spain"}' <@ attributes
? the key exists (or the element, in an array) attributes ? 'acidity'
?| / ?& any / all of these keys exist attributes ?& ARRAY['origin','certifications']
SELECT id, name, attributes ->> 'origin' AS origin
FROM   products
WHERE  attributes @> '{"certifications": ["vegan"]}'
ORDER  BY id;
id name origin
6 Aloe vera face cream 50 ml France
8 Almond body oil 200 ml Portugal
15 Ceremonial matcha green tea 30 g Portugal
18 Bamboo toothbrush Germany

Four vegan products. Notice how powerful @> is: it searches inside an array without taking it apart and without knowing which position the element is in. And SELECT id FROM products WHERE attributes ? 'volume_ml' returns 2 rows —products 6 and 8, the only ones with that key— which is how you ask "which products have this attribute defined".

JSONPath

For conditions @> can't express —numeric comparisons, filters inside arrays, expressions— PostgreSQL 12 added JSONPath, an XPath-style path language:

Operator / function What it does
@? Does the path find anything? Returns a boolean
@@ Is the JSONPath expression true?
jsonb_path_query(doc, path) Returns every matching value, as rows
jsonb_path_query_first / _array The first one / all of them in an array
SELECT id, name FROM products WHERE attributes @? '$.volume_ml ? (@ > 100)';   -- 1
SELECT DISTINCT jsonb_path_query(attributes, '$.certifications[*]') #>> '{}' AS certification
FROM   products WHERE attributes IS NOT NULL ORDER BY 1;                       -- 2

The first returns one row, product 8 (body oil, 200 ml): number 6 has 50 and the rest don't have the key. The second returns five: cosmos-organic, eu-organic, gluten-free, vegan and —as soon as you add any new product— whatever it brings with it. Read $.volume_ml ? (@ > 100) like this: $ is the root of the document, .volume_ml steps down to that key, ? (...) is a filter and @ is the current value. With [*] you walk every element of an array.

  1. Modifying documents

Operation How Example
Merge || attributes || '{"stock_min": 10}'::jsonb — adds or overwrites matching keys
Delete a key - with text attributes - 'acidity'
Delete several / by path - with an array / #- attributes - ARRAY['acidity','variety'], attributes #- '{certifications,1}'
Set a value jsonb_set(doc, path, value [, create]) Changes it if it exists; with the 4th argument at true (the default) it creates it
Insert into an array jsonb_insert(doc, path, value [, after]) Adds without overwriting; fails if the path already exists
UPDATE products
SET    attributes = jsonb_set(attributes, '{acidity}', '0.25'::jsonb)
                    || '{"reviewed": true}'::jsonb
WHERE  id = 1;

SELECT attributes ->> 'acidity' AS acidity, attributes ->> 'reviewed' AS reviewed
FROM   products WHERE id = 1;
acidity reviewed
0.25 true

And here's the performance detail you have to internalize: an UPDATE on a JSONB column rewrites the entire document. There's no such thing as "updating a key"; PostgreSQL creates a new version of the whole row with the whole document (it's MVCC, 09-02). Changing a boolean in a 200 KB document writes 200 KB. The practical consequence: JSONB is for writing rarely and reading a lot. If a field is updated constantly, that field wants to be a column.

A fine point: jsonb_set with a SQL NULL in any argument returns NULL, not the untouched document — and since it's an UPDATE, it wipes the entire document without warning. Use COALESCE, or jsonb_set(COALESCE(attributes, '{}'::jsonb), ...), when the column can be null.

  1. Expanding to rows: the bridge back to SQL

This section is the one that connects JSON to everything else in the course: turning a document into rows so you can group, join, sort and apply window functions to it.

Function Converts Into
jsonb_array_elements(doc) A JSON array One row per element (jsonb)
jsonb_array_elements_text(doc) A JSON array One row per element (text)
jsonb_each(doc) / jsonb_object_keys(doc) An object One row per pair (key, value) / per key
jsonb_to_record / jsonb_to_recordset An object / an array of objects Rows with typed columns
SELECT cert.value AS certification, COUNT(*) AS products
FROM   products AS p
CROSS JOIN LATERAL jsonb_array_elements_text(p.attributes -> 'certifications') AS cert(value)
WHERE  p.attributes ? 'certifications'
GROUP  BY cert.value
ORDER  BY products DESC, certification;
certification products
vegan 4
eu-organic 3
cosmos-organic 1
gluten-free 1

And there's nothing JSON about this any more: it's a module 4 GROUP BY over ordinary rows, with 07-04's LATERAL acting as the bridge. That's exactly the point: once expanded, a document is just another table, and everything you've learned across eleven lessons applies again. jsonb_to_recordset goes one step further and produces typed columns directly:

SELECT * FROM jsonb_to_recordset('[{"product_id":1,"quantity":2},{"product_id":15,"quantity":1}]'::jsonb)
              AS t(product_id INTEGER, quantity INTEGER);
product_id quantity
1 2
15 1

It's the canonical way to receive a list of order lines from an API and turn it into insertable rows with a single INSERT ... SELECT — far cleaner than the two parallel arrays of sp_confirm_order in 10-04.

  1. Indexing with GIN

This is where 08-03's promise gets kept. Without an index, every query with @> walks the whole table and parses every document. With a GIN index, it doesn't.

CREATE INDEX ix_products_attributes     ON products USING GIN (attributes);                -- jsonb_ops
CREATE INDEX ix_products_attributes_pth ON products USING GIN (attributes jsonb_path_ops); -- jsonb_path_ops
jsonb_ops (the default) jsonb_path_ops
What it indexes Every key and every value separately A hash of the whole path down to the value
Operators supported @>, <@, ?, ?|, ?&, @?, @@ Only @>, @? and @@
Size / speed with @> Larger / good Considerably smaller / better

The criterion: if you only query by containment (@>), jsonb_path_ops, which is smaller and faster. If you need to ask about key existence (?), there's no way around jsonb_ops. And remember from 08-03 that a GIN is built and maintained slowly: it's the "write rarely, read a lot" index, which happens to be the profile of a JSONB column too.

And the third way, which is often the best: a B-tree over an expression when the query always goes through the same key.

CREATE INDEX ix_products_origin ON products ((attributes ->> 'origin'));
SELECT id, name FROM products WHERE attributes ->> 'origin' = 'Portugal';

It returns products 8 and 15. A B-tree over (attributes ->> 'origin') is tiny compared with a GIN, supports ranges and ordering, and it's the right choice when one specific key is queried a lot. The double parentheses are compulsory: it's an expression index, 08-02's kind — and it works because ->> is IMMUTABLE, the condition 10-04 explained.

  1. The design discussion: what does NOT go in JSON

The most important part of the lesson. JSONB is so convenient that it tempts you to put everything inside it, and that's a decision you pay for over years.

What must NOT go in a JSON:

  • Anything you filter or join on constantly. category_id inside a JSON turns an index JOIN into a query you have to rewrite with ->> and cast on every use.
  • Anything with referential integrity. A foreign key can't point inside a document. If you store {"supplier_id": 7} and somebody deletes supplier 7, nobody warns you: you've just lost the guarantee 01-05 gave you for free.
  • Anything that's really a table. An array of a thousand orders inside a customer's document is an orders table in disguise, with no indexes of its own, unable to be queried on its own and rewritten in full with every purchase.
  • Anything with rules. There's no NOT NULL, no UNIQUE and no CHECK inside a JSON: a price in JSON can end up being "expensive" and nothing will stop it. And anything updated constantly, because every UPDATE rewrites the whole document (section 7).

The antipattern: using the relational database as a document store out of laziness about modelling. You recognize it by a table with an id and a data JSONB column holding everything. It works for the first month, and from the sixth on every query is a castle of ->> and ::numeric, nothing has an index, nothing has integrity and nobody knows which keys exist. If you know what the fields are, they're columns. If you genuinely need a document store, there are databases that do that far better.

And the table that settles the specific question:

Question If the answer is yes →
Do all rows have it, and do you know what it is? Column
Is it filtered, sorted or joined on frequently? Column
Does it need NOT NULL, UNIQUE, CHECK or an FK? Is it updated often on its own? Column
Is it a list of entities with a life of their own? Does it need querying or aggregating on its own? Related table
Does it vary by row type and only get read alongside the rest? Does it come from outside in a shape you don't control? JSON
Is it optional, sparse and rarely used? JSON

Applied to GreenStore: price, stock and category_id are columns, no argument; the order lines are a table, not an array inside orders; and the oil's acidity or a cream's skin type are JSON, because each category has its own and they're only shown on the product page.

Dialect note: MySQL 8 has a binary JSON type with -> and ->> (with PostgreSQL's semantics), JSON_EXTRACT, JSON_TABLE to expand to rows, and no indexes over JSON: you index generated columns instead. SQLite ships the JSON1 extension compiled in by default: json_extract(), json_each(), the ->> operator from 3.38 on, and everything stored as text. SQL Server stores JSON in NVARCHAR and queries it with JSON_VALUE, JSON_QUERY and OPENJSON to expand to rows, with indexes over computed columns. Oracle has had a native JSON type since 21c and supports JSONPath extensively. The SQL:2016 standard defines JSONPath, which is why jsonb_path_query looks so similar across engines; everything else is pure dialect.

Common Mistakes and Tips

  • Confusing -> with ->>. The first returns jsonb ("Spain", with quotes) and the second text (Spain). attributes -> 'origin' = 'Spain' never matches.
  • Comparing numbers without casting. attributes ->> 'volume_ml' > '100' compares text: '50' is greater than '100'. Always cast: (attributes ->> 'volume_ml')::int.
  • Mistyping a key. It doesn't error: it returns NULL and the row disappears silently. Check with ? which keys really exist. And using JSON instead of JSONB: no containment operators, no GIN indexes and reparsing the text on every access. JSONB unless you need the exact literal text.
  • Forgetting that an UPDATE rewrites the whole document. With large documents and frequent updates, that means bloat and VACUUM work (09-02).
  • jsonb_set on a NULL column. It returns NULL and wipes the document. COALESCE(attributes, '{}'::jsonb).
  • Indexing with GIN by default and not measuring. If you only use @>, jsonb_path_ops takes up considerably less; and if you always query the same key, a B-tree over the expression beats both.
  • Putting something with a foreign key into JSON. There's no referential integrity inside a document, and there never will be. Tip: document the expected keys. A JSON with no documented schema is a free-text field. If the set of keys is closed, validate it with a CHECK (attributes ?& ARRAY['origin']) or with a schema-validation extension.
  • Tip: start with columns and move to JSON only what's left over. The other way round doesn't work: pulling three years of data out of a JSON into typed columns is an expensive migration.
  • Tip: jsonb_pretty(attributes) to read a document in psql. And \x for expanded mode.

Exercises

Exercise 1

With the attributes column loaded as in section 4: (1) List the Spanish-origin products with their acidity, if they have one. (2) Count how many products there are per origin, sorted from most to fewest. (3) Find all the ones with aloe vera among their ingredients, using @>. (4) Add the key "reviewed": false to every product that has attributes, without overwriting anything already there.

Exercise 2

Marketing wants the catalogue in JSON for the website: an array with one object per category, and inside each one, the category's name and the array of its active products with id, name and price. Write it with jsonb_agg and jsonb_build_object, and say how many elements the outer array has.

Exercise 3

Using section 10's table, decide where each piece of data goes and justify it in one sentence:

  1. A product's average rating, shown in the listing and used for sorting.
  2. The packaging dimensions (height, width, depth), known for only some products and used only when computing shipping.
  3. A product's price change history.
  4. The full response from the payment gateway when charging an order.
  5. A product's supplier.

Solutions

Solution 1

-- 1
SELECT id, name, (attributes ->> 'acidity')::numeric AS acidity
FROM   products WHERE attributes @> '{"origin":"Spain"}' ORDER BY id;
-- 2
SELECT attributes ->> 'origin' AS origin, COUNT(*) AS products
FROM   products WHERE attributes ? 'origin' GROUP BY 1 ORDER BY products DESC, origin;
-- 3
SELECT id, name FROM products WHERE attributes @> '{"ingredients":["aloe vera"]}';
-- 4
UPDATE products SET attributes = '{"reviewed": false}'::jsonb || attributes
WHERE  attributes IS NOT NULL;

1 returns two rows: the oil (id 1) with acidity 0.25 —the one section 7's jsonb_set left behind— and the rice (id 2) with NULL, because it doesn't have that key. 2 returns Portugal 2, Spain 2, France 1 and Germany 1: four origins for the six products with attributes. 3 returns one row, the face cream (id 6), and it works because @> searches inside the array regardless of position. 4 affects 6 rows, and the order of the || is the key: '{"reviewed": false}' || attributes makes attributes win if the key already existed, whereas attributes || '{"reviewed": false}' would overwrite it. It's the difference between "add if missing" and "force the value".

Solution 2

SELECT jsonb_agg(jsonb_build_object(
           'category', cat.name,
           'products', (SELECT COALESCE(jsonb_agg(jsonb_build_object(
                                   'id', p.id, 'name', p.name, 'price', p.price)
                                 ORDER BY p.id), '[]'::jsonb)
                        FROM products AS p
                        WHERE p.category_id = cat.id AND p.active)
       ) ORDER BY cat.id) AS catalog
FROM   categories AS cat;

The outer array has 6 elements, one per category, including Supplements — which shows up with "products": [] because its only product, the Spirulina capsules, is discontinued (active = FALSE). That COALESCE(..., '[]'::jsonb) isn't decoration: without it, jsonb_agg over zero rows returns NULL and the key would come out as null instead of an empty array, which would break any code walking the list. It's exactly 04-04's problem with SUM over the empty set, now in JSON.

Solution 3

# Data Where Why
1 Average rating Column (or view/materialized view) The listing sorts and filters by it: inside a JSON it wouldn't have a usable index
2 Packaging dimensions JSON Optional, sparse and only read alongside the rest of the product when computing shipping
3 Price history Related table It's a list of entities with a life of their own, which has to be queried and aggregated on its own: it's 10-05's price_audit
4 Gateway response JSON It comes from outside in a shape you don't control and that can change without warning. Here even JSON instead of JSONB fits, if you have to verify a signature over the exact text
5 Supplier Column with an FK It has referential integrity: inside a document, ON DELETE RESTRICT doesn't exist

All five answers come out of asking three questions: is it filtered or sorted on? (column), does it have a life of its own? (table), is it optional, variable and only read together with the rest? (JSON).

Module conclusion

You close the module with the last piece of the toolbox:

  • JSONB versus JSON: decomposed binary versus literal text. JSONB normalizes keys, removes duplicates, accesses fast and is the only one that can be indexed and has containment operators. The rule: JSONB unless you need the exact text.
  • Building: to_jsonb, jsonb_build_object, jsonb_build_array, and above all the aggregates jsonb_agg and jsonb_object_agg, which return order 1 complete with its three lines and its €42.10 in a single row — the star case for an API (11-05).
  • Accessing: -> and #> return jsonb; ->> and #>> return text. That difference is the number-one source of errors, along with comparing numbers without casting and with non-existent keys returning NULL silently.
  • Searching: @> (containment, which reaches inside arrays), ?, ?| and ?& (key existence), and JSONPath with @?, @@ and jsonb_path_query for what containment can't express, such as $.volume_ml ? (@ > 100).
  • Modifying: || merges, - deletes, jsonb_set sets and jsonb_insert adds — remembering that an UPDATE rewrites the whole document, and that's why JSONB is for writing rarely and reading a lot.
  • Expanding to rows with jsonb_array_elements, jsonb_each and jsonb_to_recordset is the bridge back: once expanded, a document is just another table and all the SQL in the course works again — like the certification count, with vegan on 4 products and eu-organic on 3.
  • Indexing, closing 08-03: GIN with jsonb_ops supports every operator and takes up more space; jsonb_path_ops only @>, @? and @@, but it's smaller and faster; and a B-tree over (attributes ->> 'key') beats both when you always query the same key.
  • And the design discussion: keep out of the JSON everything you filter or join on, everything with referential integrity, everything that's really a table, everything that needs rules and everything updated constantly. The antipattern —using a relational database as a document store out of laziness about modelling— is paid back with interest. If you know what the fields are, they're columns.

And with that module 10 closes. In six lessons you've assembled the complete toolbox: views that give a query a name and encapsulate a metric, with materialized ones for expensive reports; CTEs that turn three levels of subqueries into readable steps, and WITH RECURSIVE to walk the whole org chart and the three-hop referral chain; window functions that aggregate without collapsing and solve rankings, running totals, moving averages and top N per group; procedures where order confirmation finally lives with its guaranteed atomicity; triggers that apply on their own the rules a CHECK can't reach; and JSON for what doesn't fit in a fixed schema. You're no longer learning SQL: you're using it.

What's missing isn't more functions, but context. A real system isn't a well-written query: it's a set of decisions about how the work is organized, who can see what, what gets measured and where it's called from. In module 11, Practice: real-world use cases, you'll see SQL in its environment: the use cases that come up again and again in any project; the best practices of writing, naming and maintenance that separate a database you can work with from one you're scared to touch; security —SQL injection and how it's really prevented, the permissions and roles this module has been deferring lesson after lesson—; SQL for data analysis, where 10-03's window functions turn into complete reports; and SQL in web development, with ORMs, the connection pool and the N+1 problem. The toolbox is full now; what's left is learning the craft.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved