By WebNest Studio Engineering Team
PostgreSQL Tutorial
Practical Schema Design: Normalizing vs. Using JSONB
Real schema design is a series of trade-offs, not a single "correct" answer. PostgreSQL gives you both a traditional relational toolbox (tables, foreign keys, normalization) and flexible document-style tools (JSONB, arrays), and the skill of a good schema designer is knowing when to reach for each.
This topic works through a concrete example — a product catalog — to show how the types and structures covered so far (NUMERIC, TIMESTAMPTZ, JSONB, arrays, foreign keys) come together into one coherent, defensible design.
Why it matters
Real schemas are not purely relational or purely document-shaped. A product catalogue has fields that every product shares and attributes that depend on the category. Knowing how to combine normalised columns, foreign keys and a JSONB column in one table is how PostgreSQL is used in practice.
When to Normalize
Normalize into separate tables with foreign keys whenever data needs independent identity, needs to be queried or joined on its own, must enforce referential integrity, or is shared across many parent rows. A "category" that products belong to, a "customer" that places many "orders," or "order line items" that reference both an order and a product are classic normalization candidates — each deserves its own table with a primary key and foreign keys linking them.
When JSONB (or Arrays) Are Appropriate
Reach for JSONB when the shape of the data genuinely varies per row and is not queried by its internal fields in performance-critical ways — for example, a product's variable "attributes" (a laptop has RAM and screen size; a T-shirt has size and color), or a flexible "metadata" bag for future extensibility. Use a plain array column for short, denormalized lists that always belong entirely to one row, like search tags. The rule of thumb: if you find yourself writing frequent WHERE clauses or JOINs against a specific JSONB key, that key is a strong candidate to become a real, indexed column instead.
Combining Both in One Table
Most production schemas blend both approaches deliberately: core, universally-present, frequently-queried fields become typed columns with constraints (id, name, price, created_at); a small number of foreign keys capture true relationships (category_id referencing categories); and a JSONB column absorbs the genuinely variable, less-frequently-queried extras. This keeps common queries fast and simple while still allowing flexibility where it earns its cost.
Example
A product catalog blending normalized columns, a foreign key, and JSONB for variable attributes
CREATE TABLE categories (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category_id INTEGER NOT NULL REFERENCES categories(id),
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL CHECK (price >= 0),
tags TEXT[] DEFAULT '{}',
attributes JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO categories (name) VALUES ('Laptops');
INSERT INTO products (category_id, name, price, tags, attributes)
VALUES (1, 'UltraBook 14', 999.00, ARRAY['electronics','sale'],
'{"ram_gb": 16, "screen_inches": 14}');
CREATE TABLE
CREATE TABLE
INSERT 0 1
INSERT 0 1
Common Mistakes
- Putting every column into a single JSONB "data" blob for flexibility, losing type checking, foreign keys, and efficient indexing on fields queried constantly.
- Over-normalizing genuinely variable, rarely-queried attributes into dozens of nullable columns instead of using JSONB, resulting in a wide, sparse table.
- Storing a true relationship (like which category a product belongs to) as a JSONB field or array instead of a foreign key, losing referential integrity.
- Not revisiting the design as query patterns emerge — a JSONB key that becomes a common filter condition should usually be promoted to a real, indexed column.
Exercise
This exercise combines foreign keys with JSONB, so it needs a PostgreSQL server. Design a products table for a catalogue: a generated id; a required name; an exact, positive price; a required foreign key to a categories table; and a JSONB column attributes for details that vary by category. Then write the query that lists the name and price of products in category 1 that cost less than 500.
Expected result: the table accepts products with different attributes while still enforcing the price rule and the category reference.
CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
-- TODO: create the products table
-- TODO: name and price of products in category 1 costing less than 500
Show hints
- Shared, queryable facts get real columns with constraints; only the variable details go in
attributes. - The price rule is
NUMERIC(10, 2) NOT NULL CHECK (price > 0).
Show solution
CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL CHECK (price > 0),
category_id INTEGER NOT NULL REFERENCES categories(id),
attributes JSONB NOT NULL DEFAULT '{}'
);
SELECT name, price
FROM products
WHERE category_id = 1 AND price < 500;
Quick quiz
1. A value is present on every row and is used in WHERE clauses and joins. Where should it be stored?
- A. Inside a JSONB document
- B. In a text column as a comma-separated list
- C. In its own typed column
- D. In an array
Show answer
C. A real column can have a type, constraints, a foreign key and accurate planner statistics.
2. What is JSONB well suited to in a product catalogue?
- A. The price
- B. The primary key
- C. The category reference
- D. Attributes that differ from one category to another, such as screen size or fabric
Show answer
D. Otherwise the table would need a column for every possible attribute, mostly empty.
3. What does a relational column give you that a JSONB field does not?
- A. Constraints such as NOT NULL, CHECK and foreign keys enforced by the database
- B. The ability to store text
- C. The ability to be read by SELECT
- D. Storage on disk
Show answer
A. Rules inside a JSON document have to be enforced by the application or by extra CHECK expressions.
Interview questions
How do you decide between normalised tables and JSONB for a new feature?
Show answer
Start with what is known and stable: entities with relationships, values that are filtered, sorted, joined or constrained go in normalised tables and typed columns. Use JSONB for the part that genuinely varies or is not yet understood, such as per-category attributes or settings. If a field inside the JSON later becomes important to queries, promote it to a real column. A hybrid table is common and perfectly sound.
What are the risks of putting too much into JSONB?
Show answer
The database cannot enforce types, required fields or references inside the document, so inconsistent data builds up. The planner has poor statistics about JSON contents, so query plans can be bad. Updating one field rewrites the whole document. Queries are more verbose and easier to get wrong. The schema still exists; it has merely moved, unchecked, into the application code.
Key Points to Remember
- Normalize data that needs independent identity, joins, or referential integrity.
- Use JSONB for genuinely variable, rarely-filtered attributes; use arrays for small denormalized lists owned by one row.
- A frequently-queried JSONB key is a signal to promote that field into a real typed column.
- Good schemas deliberately mix normalized columns, foreign keys, and JSONB rather than picking one approach exclusively.
Practice in the playground
Write and run your own code for this topic, or start from the exercise or an example. Runs your SQL on SQLite in your browser. Standard SQL works; a few features that exist only in PostgreSQL or MySQL do not. Your code for each topic is kept in this browser.
Run the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
These examples need a project environment on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.
Written by
WebNest Studio Engineering Team
Practical lessons from developers working with Java, Python, React, databases and production systems.
Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering