PostgreSQL Schema Design & Data Modeling - Complete Notes

A practical and interview-focused guide to relational modeling, normalization, keys, constraints, PostgreSQL data types, deliberate denormalization, arrays, enums and JSONB design.

00. The schema-design mental model

A schema is an executable description of what the business believes is true. Good design makes valid states easy to store, invalid states hard to store, and important questions easy to ask.

Tables are not application objects copied into a database. A relational model starts with facts, identity and relationships. Each row states one fact at a declared grain. Each key explains how that fact is identified. Each constraint states a rule that must remain true regardless of which application, script or administrator writes the data.

Think of a schema as the rules of a board game. Tables are the board, rows are the current game state, and constraints are rules enforced by the referee. If rules exist only in one player's application code, another player can accidentally create an impossible game state.

Design question Relational answer Failure when omitted
What does one row mean? Declare its grain Duplicates and incorrect aggregates
How is one fact identified? Primary or candidate key Ambiguous updates and references
Which facts depend on others? Foreign keys and relationship tables Orphans and contradictory copies
Which values are legal? Types, NOT NULL, CHECK and UNIQUE Invalid data discovered too late
Where should a fact live? Normalize around dependencies Update, insert and delete anomalies
What is flexible by nature? Carefully bounded JSONB or arrays Either rigid churn or an ungoverned blob
Begin every design with grain

Finish the sentence "one row represents..." for every table. For example, one orders row represents one accepted order, while one order_items row represents one product line inside one order. If the sentence uses "and" to combine independent facts, the table may contain more than one grain.

01. From requirements to a relational model

Model stable business facts first. Table names and columns should describe the domain, not the current screen layout or one API response.

  1. Collect business rules. Ask what must be unique, what is optional, what may change, what must be retained, and what should happen when related data is deleted.
  2. Find entities. Customers, products and orders usually have independent identity and life cycles, so they are strong table candidates.
  3. State the grain. Write the meaning of one row before choosing columns.
  4. Find candidate keys. Identify every minimal set of attributes that could uniquely identify the row, then select a primary key.
  5. List attributes and dependencies. Ask which key determines each value.
  6. Model relationships. Decide cardinality, optionality and ownership.
  7. Encode invariants. Choose types and constraints before adding performance indexes.
  8. Test with operations. Walk through inserts, corrections, deletes, reporting, concurrent writes and future changes.

Cardinality and optionality

Relationship Typical implementation Example
One-to-one Foreign key with UNIQUE, often shared primary key User and optional user profile
One-to-many Foreign key on the many side Customer and orders
Many-to-many Junction table with two foreign keys Orders and products through order items
Hierarchy Self-referencing foreign key Category and parent category

Optionality appears through nullability or through the absence of a related row. A nullable manager_id can mean a top-level employee. A separate optional profile table avoids a wide user row full of nulls. Pick the representation that matches the fact's identity and life cycle, not merely the representation with fewer joins.

One-to-one and many-to-many patterns
CREATE TABLE user_profiles (
    user_id      bigint PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
    display_name text NOT NULL,
    biography    text
);

CREATE TABLE order_items (
    order_id   bigint NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
    product_id bigint NOT NULL REFERENCES products(product_id) ON DELETE RESTRICT,
    quantity   integer NOT NULL CHECK (quantity > 0),
    unit_price numeric(12, 2) NOT NULL CHECK (unit_price >= 0),
    PRIMARY KEY (order_id, product_id)
);
A junction table is a real domain table

It can contain facts about the relationship, such as quantity, role, joined_at or sort_order. If the same product may appear on multiple lines in one order, use an independent order_item_id or a line number and make the key match that rule. Do not force a composite key that forbids valid business cases.

02. Normalization from 1NF to 3NF

Normalization places each fact where its key determines it. The goal is not to create the maximum number of tables. The goal is to prevent one fact from being represented inconsistently in many places.

Functional dependencies and keys

A functional dependency X -> Y means that one value of X determines exactly one value of Y within the modeled rules. If product_id -> product_name, every row with the same product id must describe the same product name. A candidate key determines every attribute in the row and is minimal, meaning no part can be removed while preserving uniqueness.

A superkey also determines the row but may include unnecessary columns. The primary key is one chosen candidate key. Other candidate keys should normally remain protected by UNIQUE. A surrogate id does not erase natural uniqueness. If email is a business identifier, adding customer_id does not make duplicate emails valid.

First normal form (1NF)

In practical database design, 1NF means each row follows one table shape, columns contain values from their declared domains, and repeating groups are not encoded as numbered columns. A design with product_1, product_2, and product_3 has a hidden list, an arbitrary maximum and awkward queries.

Replace repeating columns with rows
-- Fragile: a fourth product requires a schema change.
orders(order_id, product_1, product_2, product_3)

-- Relational: any number of items, one declared grain.
orders(order_id, customer_id, ordered_at)
order_items(order_id, line_number, product_id, quantity)
PRIMARY KEY (order_id, line_number)

PostgreSQL arrays are still single typed values, so using an array does not mechanically violate PostgreSQL's type system. The design question is behavioral: if elements need independent keys, foreign keys, attributes, updates or joins, model them as rows. Use arrays when the collection is truly one bounded value, not as a shortcut around a child table.

Second normal form (2NF)

A table is in 2NF when it is in 1NF and every non-key attribute depends on the whole candidate key, not only part of a composite key. This issue is visible in a table keyed by (order_id, product_id) that also stores product_name. Product name depends only on product id, so repeating it on every order item causes update anomalies.

Remove a partial dependency
-- Before: product_name depends only on product_id.
order_items(order_id, product_id, product_name, quantity)

-- After: each fact lives with its determinant.
products(product_id PRIMARY KEY, product_name)
order_items(order_id, product_id, quantity,
            PRIMARY KEY (order_id, product_id),
            FOREIGN KEY (product_id) REFERENCES products)

A table with a single-column candidate key cannot have a partial dependency on that key, so it is automatically safe from this specific 2NF violation. It can still violate 3NF.

Third normal form (3NF)

A table is in 3NF when it is in 2NF and non-key facts do not depend transitively on a key through another non-key fact. If employee_id -> department_id and department_id -> department_name, storing department name in every employee row duplicates a department fact.

Remove a transitive dependency
-- Before: department_name is determined through department_id.
employees(employee_id, full_name, department_id, department_name)

-- After: department facts have one home.
departments(department_id PRIMARY KEY, department_name UNIQUE)
employees(employee_id PRIMARY KEY, full_name,
          department_id REFERENCES departments)
Anomaly Duplicated design Normalized design
Update Rename a department in hundreds of rows Update one department row
Insert Cannot create a department before its first employee Department has independent existence
Delete Deleting the last employee loses department facts Department remains until explicitly deleted

3NF, BCNF and practical judgment

Boyce-Codd normal form (BCNF) strengthens the rule: for every non-trivial dependency X -> Y, X must be a superkey. Most everyday transactional models that correctly reach 3NF also satisfy BCNF. Rare overlapping candidate-key cases can satisfy 3NF but not BCNF. In interviews, explain dependencies and anomalies instead of reciting only definitions.

The memorable normalization sentence

Every non-key fact should depend on the key, the whole key, and nothing but the key. Then verify that alternate candidate keys and real business dependencies also obey the rule.

03. Deliberate denormalization

Denormalization is an intentional duplicate or precomputed representation added for a measured read requirement. It is not the accidental duplication that normalization helps discover.

Reasonable examples include:

  • An immutable order line stores the product name and unit price agreed at purchase time. These are historical snapshot facts, not merely caches of the current product row.
  • A summary table stores daily sales totals for a heavily used analytical dashboard.
  • A materialized view precomputes an expensive, refreshable report.
  • A search document combines text from several tables for specialized indexing.
  • A bounded JSONB payload retains a third-party event exactly enough for audit or replay.

Before denormalizing, state the source of truth, the synchronization mechanism, acceptable staleness, repair procedure and measurable read benefit. Options include updating both copies in one transaction, generated columns for same-row expressions, triggers, asynchronous consumers, scheduled refreshes and rebuildable caches. Each option changes failure modes and operational cost.

Do not denormalize based on fear of joins

PostgreSQL is designed to join related rows. First create a correct normalized model, add the indexes required by real access paths, inspect query plans and measure production-shaped data. Duplicate facts only when a demonstrated workload justifies the new consistency burden.

Question Safe answer before denormalizing
Which copy is authoritative? Exactly one source is named
How do copies stay aligned? One explicit synchronous or asynchronous mechanism
Can readers see stale data? A quantified staleness contract
How is drift detected? Reconciliation query or invariant monitor
How is it repaired? Idempotent rebuild or backfill procedure
What does it improve? Measured latency, throughput or resource result

04. Keys and identity

Keys are integrity rules before they are performance tools. They define identity, prevent duplicates and give relationships a stable target.

Natural, surrogate and composite keys

Key style Strength Risk
Natural key Already meaningful and prevents domain duplicates May be wide, mutable or controlled externally
Surrogate key Narrow, stable and convenient for references Does not protect natural uniqueness by itself
Composite key Expresses identity made from several facts Widens child foreign keys and indexes

A country code may be a stable natural key in one system. An email address is often a poor primary key because case rules, correction and reassignment complicate its identity. A junction table often has a natural composite key such as (student_id, course_id), unless the domain permits multiple enrollment attempts and therefore needs another identifying attribute.

Surrogate identity plus protected business key
CREATE TABLE customers (
    customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email       text NOT NULL,
    full_name   text NOT NULL,
    CONSTRAINT customers_email_unique UNIQUE (email)
);

GENERATED ALWAYS AS IDENTITY is SQL-standard syntax backed by an implicit sequence. BY DEFAULT permits an explicit inserted value to take precedence, which can help controlled imports but weakens protection against accidental manual ids. Sequence-generated values can have gaps after rollbacks, caching or conflicts. A primary key promises uniqueness, not consecutive numbering.

UUID keys

PostgreSQL's native uuid type stores a 128-bit identifier. UUIDs are useful when many independent writers must create identifiers without first coordinating with one database sequence, when public ids should not expose simple counts, or when offline object creation is required.

Database-generated UUID key
CREATE TABLE documents (
    document_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    title       text NOT NULL
);

Random UUIDs are wider than bigint keys and have less index locality, so primary and foreign-key indexes consume more space and may perform more random page work. Time-ordered UUID variants can improve locality while keeping distributed generation properties. Current PostgreSQL releases expose native UUID generation functions, but verify the functions available in the exact server version before choosing migration syntax. Never store UUIDs as text merely for convenience: the native type validates input and is more compact.

Internal and public identifiers can differ

A system can use a narrow bigint for internal joins and a separate unique UUID for external URLs or cross-system exchange. This adds one index and one mapping, so use it only when the two identity roles genuinely differ.

05. Constraints as executable rules

Constraints protect data at the shared storage boundary. Application validation improves error messages, while database constraints guarantee that every writer obeys the invariant.

NOT NULL and defaults

Use NOT NULL when absence has no valid business meaning. PostgreSQL treats an unmarked column as nullable. A default supplies a value only when an insert omits the column or explicitly asks for DEFAULT; it does not replace an explicit SQL null.

Required facts and insert defaults
CREATE TABLE accounts (
    account_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    active     boolean NOT NULL DEFAULT true,
    created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
    closed_at  timestamptz,
    CONSTRAINT accounts_closed_state CHECK (
        (active AND closed_at IS NULL)
        OR (NOT active AND closed_at IS NOT NULL)
    )
);

Null is not an empty string, zero, false or a magic date. It means unknown or absent according to the model. If several different missing meanings matter, model a status or related entity rather than asking one null to carry hidden categories.

CHECK constraints

A check constraint accepts a row when its expression is true or null. Therefore CHECK (price >= 0) does not reject a null price. Add NOT NULL when the value is required. Name important constraints so errors and migrations explain the violated rule.

Single-column and cross-column checks
CREATE TABLE promotions (
    promotion_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code         text NOT NULL,
    starts_at    timestamptz NOT NULL,
    ends_at      timestamptz NOT NULL,
    percent_off  numeric(5, 2) NOT NULL,
    CONSTRAINT promotions_percent_range
        CHECK (percent_off > 0 AND percent_off <= 100),
    CONSTRAINT promotions_time_order
        CHECK (ends_at > starts_at),
    CONSTRAINT promotions_code_unique UNIQUE (code)
);
CHECK is for the current row

Do not use a CHECK expression that queries other rows or tables. PostgreSQL assumes the result is stable for the same row and does not continuously recheck it after other data changes. Use UNIQUE, FOREIGN KEY, EXCLUDE, a better model, or carefully designed triggers for rules that span rows.

UNIQUE and null semantics

A unique constraint can cover one or many columns and automatically creates a unique B-tree index. By default, PostgreSQL considers nulls distinct for uniqueness, so multiple null entries are allowed. Use NULLS NOT DISTINCT when the business rule treats null as one shared value.

Composite uniqueness and one optional singleton
CREATE TABLE memberships (
    organization_id bigint NOT NULL REFERENCES organizations,
    user_id         bigint NOT NULL REFERENCES users,
    external_ref    text,
    role            text NOT NULL,
    UNIQUE (organization_id, user_id),
    UNIQUE NULLS NOT DISTINCT (organization_id, external_ref)
);

A partial unique index can express rules such as one active subscription per customer while retaining historical inactive rows. It is an index-backed rule rather than a SQL unique constraint, and foreign keys cannot generally target a partial unique index.

Conditional uniqueness
CREATE UNIQUE INDEX subscriptions_one_active_per_customer
    ON subscriptions (customer_id)
    WHERE ended_at IS NULL;

PRIMARY KEY

A primary key combines uniqueness and non-nullability, creates a unique B-tree index, documents the table's chosen identity and becomes the default referenced target when a foreign key omits a column list. PostgreSQL allows only one primary key per table but any number of alternate unique constraints.

FOREIGN KEY and referential actions

A foreign key requires each non-null referencing value to match a row in a referenced primary key or suitable unique key. Column count and compatible types must match. Composite foreign keys preserve multi-column identity, and a self-reference models a hierarchy.

Action Meaning when the parent changes Use when
NO ACTION Default; violation may be checked later if deferrable Relationship must be repaired or parent change rejected
RESTRICT Reject immediately; cannot defer this action Independent referenced object must be retained
CASCADE Propagate delete or referenced-key update Child is an owned component of parent
SET NULL Clear the reference Relationship is optional after parent removal
SET DEFAULT Set reference to its default A valid sentinel parent is a real domain rule
Ownership-aware foreign keys
CREATE TABLE order_items (
    order_id   bigint NOT NULL
        REFERENCES orders(order_id) ON DELETE CASCADE,
    product_id bigint NOT NULL
        REFERENCES products(product_id) ON DELETE RESTRICT,
    line_no    integer NOT NULL CHECK (line_no > 0),
    PRIMARY KEY (order_id, line_no)
);

CREATE INDEX order_items_product_id_idx ON order_items (product_id);

PostgreSQL indexes referenced primary or unique keys, but it does not automatically create an index on the referencing columns. Add child-side indexes when parent updates or deletes and joins need to locate referencing rows efficiently. A composite primary key beginning with order_id already supports lookups by order id, but it does not efficiently support a lookup by product_id alone.

CASCADE expresses ownership, not convenience

Deleting an order should usually delete its component line items. Deleting a product should not normally erase historical orders. Choose actions from domain life cycles and test the full transitive cascade before enabling it.

Deferrable constraints

PostgreSQL can defer eligible UNIQUE, PRIMARY KEY, FOREIGN KEY and EXCLUDE checks until transaction end when declared DEFERRABLE. This helps operations that temporarily violate a rule, such as swapping two unique positions. It does not make invalid committed data legal.

Swap positions inside one transaction
CREATE TABLE queue_items (
    queue_id bigint NOT NULL,
    item_id  bigint NOT NULL,
    position integer NOT NULL,
    CONSTRAINT queue_position_unique
        UNIQUE (queue_id, position) DEFERRABLE INITIALLY IMMEDIATE
);

BEGIN;
SET CONSTRAINTS queue_position_unique DEFERRED;
UPDATE queue_items SET position = -1 WHERE item_id = 10;
UPDATE queue_items SET position = 1  WHERE item_id = 20;
UPDATE queue_items SET position = 2  WHERE item_id = 10;
COMMIT;

06. Choosing data types deliberately

A type defines representation, valid operations, comparison rules, storage behavior and available indexes. Choose the type that matches meaning, not the format used by one client.

Meaning Strong default Avoid
Whole count integer or bigint text or floating point
Exact money-like quantity numeric(p, s), or integer minor units by contract real or double precision
Measured scientific value double precision numeric when approximation is intended and speed matters
Human text text, with CHECK if a limit is a rule char(n) padding without a fixed-format requirement
Yes/no state boolean 0/1 text conventions
Global identifier uuid varchar UUID representation
Real instant timestamptz local timestamp with an unstated zone
Local wall-clock value timestamp without time zone invented UTC conversion for a non-instant
Structured flexible payload jsonb text containing unvalidated JSON

PostgreSQL's text and varchar have the same general storage and performance characteristics. Use varchar(n) only when length n is a genuine rule, not a guess. char(n) blank-pads values and is rarely a good default. Phone numbers, postal codes and account codes are text even when they contain digits, because arithmetic is not their meaning and leading zeros may matter.

07. TIMESTAMPTZ versus TIMESTAMP

Choose between an instant on the global timeline and a local calendar or wall-clock value. The type names are confusing, so reason from meaning rather than spelling.

Type Represents Typical examples
timestamptz One instant, displayed in the session time zone created_at, paid_at, event occurrence
timestamp Date and wall-clock fields without a zone or offset Store opens at local 09:00, floating schedule template
date Calendar date without time Birthday, billing date
time Time of day without a date Recurring local opening time

PostgreSQL converts timestamptz input to an internal UTC instant. It does not retain the original zone name. On output, it converts the instant to the session's TimeZone. Therefore the same value can display as different local times in Kolkata and New York while still representing one instant.

One instant, two displays
SET TIME ZONE 'Asia/Kolkata';
SELECT TIMESTAMPTZ '2026-07-22 10:00:00+05:30';

SET TIME ZONE 'America/New_York';
SELECT TIMESTAMPTZ '2026-07-22 10:00:00+05:30';
-- The displayed wall time changes; the instant does not.

Plain timestamp means timestamp without time zone. If input contains an offset but the target type is plain timestamp, PostgreSQL ignores the zone indication rather than converting the value. Use explicit types in literals and parameterized client values to avoid accidental interpretation.

Future schedules and named zones

A future appointment described as "09:00 in Europe/Paris" may need both a local timestamp and an IANA zone name. Storing only the current UTC conversion can lose the user's intended civil time if time-zone rules later change. Conversely, an event that already occurred should normally be stored as an instant. Model the intent.

Recurring or future local schedule
CREATE TABLE appointments (
    appointment_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    local_starts_at timestamp NOT NULL,
    time_zone_name  text NOT NULL,
    CONSTRAINT appointments_zone_known CHECK (
        time_zone_name = ANY (
            ARRAY['Asia/Kolkata', 'Europe/Paris', 'America/New_York']
        )
    )
);

A fixed application-managed allow-list check is shown only for a small bounded domain. If zones are configurable, use a referenced table populated from supported policy or validate against pg_timezone_names in controlled application logic. A CHECK constraint should not query a changing catalog.

A time-zone offset is not a time-zone identity

+01:00 does not describe daylight-saving transitions or historical rules. Use an IANA name such as Europe/Paris when future civil-time behavior matters. Avoid time with time zone for ordinary scheduling because a zone rule generally needs a date.

08. NUMERIC versus floating point

Use NUMERIC when decimal exactness is part of correctness. Use floating point when approximation is expected and its range or calculation speed is valuable.

Type Behavior Good fit
numeric(p, s) Exact decimal arithmetic where possible; declared rounding and range Money, tax rates, contractual quantities
numeric Unconstrained precision and scale within implementation limits General arbitrary-precision values with varied scale
real Four-byte approximate IEEE 754 value Space-sensitive approximate measurements
double precision Eight-byte approximate IEEE 754 value Scientific calculations and measured values

In numeric(12, 2), precision 12 is the total significant decimal digits and scale 2 is the fractional digits. PostgreSQL rounds inputs with more fractional digits to the declared scale, then rejects a result whose integer part is too wide. Do not assume excessive fractional input always raises an error.

Exact price model
CREATE TABLE invoice_lines (
    invoice_id bigint NOT NULL REFERENCES invoices ON DELETE CASCADE,
    line_no    integer NOT NULL,
    quantity   numeric(12, 3) NOT NULL CHECK (quantity > 0),
    unit_price numeric(14, 4) NOT NULL CHECK (unit_price >= 0),
    PRIMARY KEY (invoice_id, line_no)
);

SELECT quantity * unit_price AS unrounded_line_total
FROM invoice_lines;

Define where rounding occurs and which rounding rule the business accepts. Rounding each line and then summing can differ from summing high-precision values and rounding once. Currency also needs a currency code; the number 100 alone does not distinguish INR from USD. For a single-currency, fixed-scale domain, integer minor units can be excellent, but the contract must handle currencies with different minor-unit scales.

Never compare floats as if every decimal is exact

Many decimal fractions have no exact binary floating-point representation. Equality and accumulated arithmetic can surprise you. Compare within a domain-approved tolerance when using approximate values, and use NUMERIC for financial invariants.

09. Arrays and enums

Arrays and enums are strong PostgreSQL features when their boundaries match the domain. They become costly when used to hide relationships or fast-changing reference data.

Arrays

PostgreSQL supports arrays of built-in and user-defined types. They are useful for small, bounded collections that are naturally read and replaced with the parent, such as RGB samples, fixed configuration coordinates or tags in a simple document-like model. Operators include containment @>, contained-by <@, overlap &&, concatenation and = ANY(array). GIN indexes can accelerate common containment and overlap queries.

Bounded array with shape checks
CREATE TABLE brand_themes (
    brand_id bigint PRIMARY KEY REFERENCES brands ON DELETE CASCADE,
    rgb      smallint[] NOT NULL,
    CONSTRAINT brand_themes_rgb_shape CHECK (
        array_ndims(rgb) = 1
        AND cardinality(rgb) = 3
        AND 0 <= ALL (rgb)
        AND 255 >= ALL (rgb)
    )
);

CREATE INDEX articles_tags_gin ON articles USING GIN (tags);
SELECT * FROM articles WHERE tags @> ARRAY['postgresql'];

Declared array dimensions and lengths in a column type are documentation only in PostgreSQL; the database does not enforce them. Add explicit checks when shape matters. Array lower bounds can differ from 1, especially after construction or slicing, so use functions such as cardinality, array_length and unnest instead of assuming a bound.

Prefer a child table when elements are entities

If tags need foreign keys, metadata, unique ownership, individual updates, frequent joins or analytics, use article_tags(article_id, tag_id). Arrays cannot attach a foreign key to each element, and concurrent updates rewrite the parent row's array value.

Enums

A PostgreSQL enum is a separate ordered type with a static set of case-sensitive labels. It gives compact storage, type safety and meaningful ordering. It fits a small, stable, shared vocabulary such as compass direction or a truly stable state machine.

Enum declaration and use
CREATE TYPE order_status AS ENUM (
    'pending',
    'paid',
    'shipped',
    'cancelled'
);

CREATE TABLE orders (
    order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status   order_status NOT NULL DEFAULT 'pending'
);

ALTER TYPE order_status ADD VALUE 'refunded' AFTER 'paid';

Existing enum labels can be added or renamed, but cannot be removed and cannot have their sort order rearranged without replacing the type. Each enum type is distinct, even if two types contain identical labels. These migration constraints make enums a poor fit for user-managed categories, translated labels, frequently changing workflows or values with metadata.

Requirement Prefer
Small, stable, typed vocabulary PostgreSQL enum
Simple values with easy migrations text plus CHECK
Values have labels, permissions or active flags Reference table plus foreign key
Values are tenant-configurable Tenant-scoped reference table

10. JSONB without abandoning the model

JSONB is a typed, queryable document value inside PostgreSQL. It is best for genuinely flexible or externally shaped data, not as a default replacement for columns, keys and relationships.

JSON versus JSONB

json stores an exact copy of input text and reparses it during processing. It preserves insignificant whitespace, key order and duplicate keys. jsonb stores a decomposed binary representation, discards whitespace and object-key order, and keeps only the last duplicate object key. JSONB is normally faster to process and supports indexes, so most application schemas should prefer it. Use json only when preserving the original lexical representation is itself a requirement.

SQL null and JSON null are different. A SQL null means the whole column is absent. The JSONB value 'null'::jsonb is a present JSON scalar. A missing object key is different again. Define which states are legal before writing extraction logic.

Column, related table or JSONB?

Question Use a typed column or table when JSONB is reasonable when
Is it core identity or a relationship? It needs keys, FKs or joins It is an opaque external reference detail
Is it frequently filtered or sorted? Stable access path needs typed comparison Queries are varied and containment-oriented
Must every row obey one rule? NOT NULL, CHECK and type express the rule Shape legitimately varies by source or subtype
Does it change independently? Separate row avoids whole-document updates Document is one atomic business value
Is the schema user-defined? Stable platform fields remain columns Bounded custom fields fit JSONB

A useful hybrid keeps stable searchable facts as columns and flexible attributes in JSONB. For a product catalog, product_id, seller id, name, price, currency and lifecycle status are strong columns. Category-specific attributes such as screen size or fabric weave may fit a bounded JSONB object if their shapes vary substantially.

Hybrid relational and document model
CREATE TABLE products (
    product_id  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    seller_id   bigint NOT NULL REFERENCES sellers,
    sku         text NOT NULL,
    name        text NOT NULL,
    price       numeric(12, 2) NOT NULL CHECK (price >= 0),
    currency    text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
    attributes  jsonb NOT NULL DEFAULT '{}'::jsonb,
    CONSTRAINT products_seller_sku_unique UNIQUE (seller_id, sku),
    CONSTRAINT products_attributes_object CHECK (
        jsonb_typeof(attributes) = 'object'
    )
);

Reading and updating JSONB

Extraction, containment, existence and update
-- -> returns JSONB; ->> returns text.
SELECT
    attributes -> 'dimensions' AS dimensions_json,
    attributes ->> 'color' AS color_text
FROM products;

-- Structure-aware containment.
SELECT *
FROM products
WHERE attributes @> '{"color":"black"}'::jsonb;

-- Top-level key existence.
SELECT *
FROM products
WHERE attributes ? 'warranty_months';

-- Replace or create a nested path and return a new document value.
UPDATE products
SET attributes = jsonb_set(
    attributes,
    '{dimensions,width_cm}',
    to_jsonb(42.5::numeric),
    true
)
WHERE product_id = 10;

Extraction operators return null rather than failing when the requested structure is absent, so a typo can look like missing data. Cast extracted text only after accounting for missing, JSON null, empty and malformed values. Parameterize JSON values through the client driver instead of building JSON strings manually.

A small field update still updates the row

JSONB functions produce a new document value. PostgreSQL's row-level concurrency and MVCC apply to the entire table row, so two transactions changing different keys can still conflict or lose an application-side read-modify-write update. Keep documents at a manageable atomic size and use SQL-side updates plus concurrency controls where necessary.

11. Indexing inside JSONB

Match the index expression and operator class to the exact query shapes. A JSONB column is not automatically fast merely because PostgreSQL can query it.

Whole-document GIN indexes

Flexible containment and existence index
CREATE INDEX products_attributes_gin
    ON products USING GIN (attributes);

SELECT *
FROM products
WHERE attributes @> '{"color":"black"}'::jsonb;

SELECT *
FROM products
WHERE attributes ? 'warranty_months';

The default jsonb_ops GIN operator class supports key-existence operators ?, ?|, ?&, containment @>, and JSON path matching with @? and @@. It is flexible because it indexes every key and value, but that flexibility can produce a large index and write overhead.

Smaller containment-focused operator class
CREATE INDEX products_attributes_path_gin
    ON products USING GIN (attributes jsonb_path_ops);

jsonb_path_ops supports @>, @? and @@, but not the key-existence operators. It is usually smaller and more specific for containment-heavy workloads. It can perform poorly for searches for structures with no values, such as an empty object, because it does not create entries for every key by itself. Choose from observed query operators, not from a generic rule.

Targeted expression indexes

A B-tree expression index is often better for equality, range or ordering on one frequently used scalar. The query expression must match the indexed expression, including casts.

Typed scalar expression indexes
CREATE INDEX products_color_idx
    ON products ((attributes ->> 'color'));

CREATE INDEX products_warranty_months_idx
    ON products (((attributes ->> 'warranty_months')::integer));

SELECT *
FROM products
WHERE (attributes ->> 'warranty_months')::integer >= 24;

If malformed JSON can make the cast fail, the index cannot be built reliably. Validate the field, use a safe immutable extraction function, or promote the value to a real typed column. A targeted GIN expression index can also index one nested collection.

Index only a frequently searched nested array
CREATE INDEX products_compatibility_gin
    ON products USING GIN ((attributes -> 'compatible_with'));

SELECT *
FROM products
WHERE attributes -> 'compatible_with' ? 'device-42';

Promoting a stable JSON field

When a JSON key becomes required, heavily joined, frequently sorted or governed by a stable type, promote it to a column. During a transition, a stored generated column can expose a typed same-row expression for constraints and ordinary indexes, provided the expression is immutable and all existing documents are valid.

Generated typed projection
ALTER TABLE products
ADD COLUMN color text
GENERATED ALWAYS AS (attributes ->> 'color') STORED;

CREATE INDEX products_generated_color_idx ON products (color);
Indexes have a write and storage budget

Every GIN or expression index consumes disk, WAL, cache and maintenance work on inserts and updates. Start from actual predicates, sort requirements and selectivity. Topic 4 covers index structures and planner decisions in depth.

12. Complete transactional schema example

This compact shop model combines grain, normalization, keys, historical snapshots, constraints, deliberate types and bounded JSONB.

Customers, products, orders and immutable line snapshots
CREATE TYPE order_status AS ENUM (
    'pending', 'paid', 'shipped', 'cancelled'
);

CREATE TABLE customers (
    customer_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    email       text NOT NULL,
    full_name   text NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT customers_email_unique UNIQUE (email),
    CONSTRAINT customers_email_nonblank CHECK (btrim(email) <> '')
);

CREATE TABLE products (
    product_id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    sku           text NOT NULL UNIQUE,
    name          text NOT NULL,
    current_price numeric(12, 2) NOT NULL CHECK (current_price >= 0),
    attributes    jsonb NOT NULL DEFAULT '{}'::jsonb,
    active        boolean NOT NULL DEFAULT true,
    CONSTRAINT products_attributes_object
        CHECK (jsonb_typeof(attributes) = 'object')
);

CREATE TABLE orders (
    order_id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id      uuid NOT NULL REFERENCES customers ON DELETE RESTRICT,
    status           order_status NOT NULL DEFAULT 'pending',
    currency         text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
    ordered_at       timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
    shipping_name    text NOT NULL,
    shipping_address jsonb NOT NULL,
    CONSTRAINT orders_shipping_address_object
        CHECK (jsonb_typeof(shipping_address) = 'object')
);

CREATE TABLE order_items (
    order_id          bigint NOT NULL REFERENCES orders ON DELETE CASCADE,
    line_no           integer NOT NULL CHECK (line_no > 0),
    product_id        bigint NOT NULL REFERENCES products ON DELETE RESTRICT,
    product_name      text NOT NULL,
    quantity          integer NOT NULL CHECK (quantity > 0),
    agreed_unit_price numeric(12, 2) NOT NULL CHECK (agreed_unit_price >= 0),
    PRIMARY KEY (order_id, line_no)
);

CREATE INDEX orders_customer_ordered_idx
    ON orders (customer_id, ordered_at DESC, order_id DESC);
CREATE INDEX order_items_product_idx ON order_items (product_id);
CREATE INDEX products_attributes_gin ON products USING GIN (attributes);

The product's current name and price are normalized in products. The order line also stores the name and price accepted at checkout because these are historical agreement facts. They should not silently change when a catalog editor changes the product. The shipping address is an order-owned snapshot whose structure may vary by country, so bounded JSONB can be reasonable. If the system must query postal code or country heavily, promote those fields to typed columns.

The database alone does not make an order immutable after payment. A state transition procedure, permissions or trigger may be needed if only specific transitions are legal. Constraint design and authorization design are related but separate responsibilities.

13. Schema evolution and safe migrations

A production schema is changed while old data and often old application versions still exist. Safe changes separate expansion, backfill, validation and cleanup.

  1. Expand. Add a nullable column, new table or compatible representation without breaking current code.
  2. Write both or translate. Deploy code that can populate the new representation while still serving the old contract if needed.
  3. Backfill in bounded batches. Avoid one enormous transaction, monitor locks, WAL, replication lag and application latency.
  4. Validate. Compare old and new representations, then validate constraints.
  5. Switch reads. Move consumers after data and indexes are ready.
  6. Contract. Stop old writes, remove obsolete columns or compatibility code in a later release.
Add and validate a large-table check in stages
ALTER TABLE products
ADD CONSTRAINT products_price_nonnegative
CHECK (current_price >= 0) NOT VALID;

-- New and changed rows are protected while existing rows can be repaired.
ALTER TABLE products
VALIDATE CONSTRAINT products_price_nonnegative;

Adding a foreign key with NOT VALID follows the same broad idea: new writes are checked, and existing rows are scanned later during validation. Lock behavior and version-specific DDL optimizations matter on large tables, so test the exact migration on production-shaped data.

Promoting JSONB to a column

  1. Add the new nullable typed column.
  2. Backfill only documents with a valid convertible value.
  3. Record and repair exceptions instead of hiding failed casts.
  4. Deploy dual-write or make one representation generated from the other.
  5. Add indexes and constraints after the values are trustworthy.
  6. Switch reads and later remove the old JSON key if duplication is unnecessary.
Type changes are data migrations

Changing text to UUID, timestamp or numeric can fail on one bad row and may rewrite a large table. Define explicit USING conversion logic, audit invalid values first, and plan rollback or forward repair. Do not cast blindly during a high-risk deployment.

14. Performance implications of modeling choices

Correct grain and constraints often improve performance because they give the optimizer reliable structure and keep rows and indexes focused. Every convenience still has a cost profile.

Choice Read effect Write and storage effect
Narrow surrogate key Compact joins and indexes Needs separate natural UNIQUE rule
Wide composite key Can satisfy relationship lookup directly Propagates width into child rows and indexes
UUID key Distributed identity without sequence round trip Wider indexes; random versions reduce locality
Normalized tables Joins required for combined views Small authoritative updates and fewer anomalies
Denormalized copies Can reduce expensive repeated computation More bytes, writes and consistency work
Large JSONB document Flexible retrieval as one value Whole-row locks, row versions and large GIN entries
Many constraints Reliable assumptions and cleaner data Validation work, normally worth correctness

Foreign keys perform checks during writes and can acquire locks needed to preserve referential integrity. Missing child-side indexes can make parent deletes and updates scan the child table. Unique constraints add indexes and therefore write amplification. These are not reasons to remove integrity; they are reasons to select intentional keys and indexes.

Very wide rows reduce the number of rows that fit on a page. Frequently changed columns can make updates more expensive, and large out-of-line values add TOAST access. Separate a large optional one-to-one payload when its access and update pattern differs from the core row, but do not split tables without measuring the resulting joins and life-cycle complexity.

15. Common mistakes and diagnostic questions

Symptom Likely design cause Correction
Same fact has conflicting values Partial or transitive dependency Give the fact one authoritative table
Duplicate business entities Surrogate key without natural UNIQUE rule Protect the actual candidate key
Orphan rows appear Application-only relationship validation Add and validate a foreign key
CHECK permits null unexpectedly Null makes the expression unknown Add NOT NULL when absence is invalid
Unique nullable value repeats Nulls are distinct by default Use NULLS NOT DISTINCT if that is the rule
Parent deletion is slow Unindexed referencing foreign key Add the child-side access index
Historical order changes with catalog Snapshot fact was not captured Store agreed values on the order line
Timestamps shift or lose meaning Instant and wall time were confused Choose timestamptz or local timestamp plus zone by meaning
Financial totals differ by a cent Float or inconsistent rounding stage Use numeric and define rounding contract
Enum migration becomes painful Vocabulary was not actually static Use text plus CHECK or a reference table
JSON query misses an index Operator or expression does not match index Match query shape and inspect the plan
Two JSON key updates conflict Document shares one table row Use atomic SQL update, locking, or split independent facts
  1. State the grain and candidate keys for every table.
  2. Look for facts repeated across rows and identify their determinant.
  3. Test null, empty, duplicate, boundary and invalid relationship inputs.
  4. Walk every foreign key's delete and update action in plain business language.
  5. Check whether child foreign-key columns have indexes for important access paths.
  6. Classify each time value as instant, date, local time or local timestamp plus named zone.
  7. Classify each number as exact count, exact decimal or approximate measurement.
  8. For every JSON key, ask whether it now deserves a column or related table.
  9. For every duplicate, identify source of truth, synchronization and repair.
  10. Measure query plans and write cost using production-shaped data.

16. Practice problems

1. An order table has product_1, product_2 and product_3 columns. Redesign it.

Keep one row per order in orders. Add order_items with one row per line, a foreign key to the order, a foreign key to the product, quantity, agreed price, and a primary key such as (order_id, line_no). This removes the arbitrary three-item limit and permits normal joins and aggregates.

2. Users have a surrogate id, but duplicate usernames must be impossible within one tenant.

Keep the surrogate primary key and add UNIQUE (tenant_id, username). If username comparison is case-insensitive by policy, encode the same normalization in writes and the unique rule, for example with an appropriate collation, citext extension, or a lower-case expression index chosen deliberately.

3. A product can have user-defined attributes. Where should they live?

Keep identity, ownership, lifecycle and frequently queried stable fields in typed columns. Bounded custom attributes can live in a JSONB object with a shape check. Add targeted indexes for proven access paths, and promote any attribute that becomes required, relational or heavily queried.

4. Model one active subscription per customer with retained history.

Store each subscription as its own row with start and end instants. Add a partial unique index on customer_id where ended_at is null. Add checks that an end follows the start. Use a transaction and locking appropriate to the creation workflow.

5. Store a webinar at 09:00 next year in the organizer's location.

If the promise is a local civil time, store a timestamp without time zone plus the IANA zone name. Derive an instant for delivery using the applicable time-zone rules. If the instant is contractually fixed regardless of later rule changes, store timestamptz and optionally retain the display zone separately.

6. A CHECK constraint calls a function that queries another table. What is wrong?

PostgreSQL assumes a CHECK result remains stable for the same row and does not recheck it after other-table changes. The database can reach a violating state and dump/restore can fail. Use a foreign key, unique or exclusion constraint, remodeled data, or a carefully maintained trigger.

17. Interview Q&A

Q: What is normalization and why use it?

Normalization organizes facts according to keys and functional dependencies. It reduces duplicated truth and prevents update, insert and delete anomalies. It is a correctness tool, not a requirement to maximize table count.

Q: Explain 1NF, 2NF and 3NF simply.

1NF removes repeating groups and gives rows a consistent relational shape. 2NF requires every non-key fact to depend on the whole composite key, not part of it. 3NF removes dependencies of non-key facts on other non-key facts. Always connect these definitions to concrete anomalies.

Q: When is denormalization justified?

After a correct normalized design and measured workload show a material read or reporting problem. The design must name the source of truth, update mechanism, allowed staleness, drift detection and rebuild path. Historical snapshots are also legitimate because they represent a different time-specific fact.

Q: Primary key versus unique constraint?

Both enforce uniqueness through a unique B-tree index, but a primary key also forces non-null, declares the chosen row identity, and is the default foreign-key target. A table has at most one primary key but can have several alternate unique constraints.

Q: Does a foreign key automatically create an index?

PostgreSQL indexes the referenced primary or unique key, but does not automatically index the referencing columns. Add a child-side index when joins and parent changes need to find those rows efficiently, accounting for any existing composite index prefix.

Q: NO ACTION versus RESTRICT?

NO ACTION is the default and can permit a temporary violation until a deferred constraint is checked later. RESTRICT rejects the referenced change immediately and cannot be deferred. Both usually prevent the parent change when references remain.

Q: Why can CHECK allow a null?

A CHECK passes when its expression is true or unknown. Comparisons involving null commonly produce unknown. Add NOT NULL when absence itself is invalid, and use CHECK for the value rule.

Q: UUID versus bigint primary key?

Bigint identity keys are narrow, ordered and efficient inside one database. UUIDs support decentralized generation and opaque globally unique identifiers but use wider indexes, and random variants have weaker locality. Choose from generation topology, exposure and workload, not fashion.

Q: TIMESTAMPTZ versus TIMESTAMP?

Timestamptz represents one instant and displays it in the session zone. Timestamp without time zone stores local date-time fields without identifying an instant. Use timestamptz for occurred events and timestamp plus a named zone when future civil-time intent must be preserved.

Q: NUMERIC versus DOUBLE PRECISION?

Numeric provides exact decimal behavior where possible and is appropriate for money and contractual quantities, at greater CPU and storage cost. Double precision is fast approximate binary floating point for measurements and scientific work. Decimal equality must not be assumed for floats.

Q: Enum, CHECK or reference table?

Use enum for a small stable typed set, text plus CHECK for simple values that need easy schema evolution, and a reference table when values have metadata, life cycles, permissions, translations or tenant ownership.

Q: When should a value be a column instead of JSONB?

Prefer a column when it is required, stable, relational, frequently filtered or sorted, or needs strong type and constraint semantics. JSONB fits variable, bounded, document-like attributes that form one atomic value. Hybrid models are often strongest.

Q: jsonb_ops versus jsonb_path_ops?

The default jsonb_ops GIN class supports existence, containment and JSON path operators and is flexible. jsonb_path_ops supports containment and JSON path matching but not key-existence; it is usually smaller and more specific for those supported searches. Pick from actual predicates.

18. One-screen cheat sheet

  • Grain: state exactly what one row represents before choosing columns.
  • Candidate key: minimal attributes that uniquely identify the row.
  • 1NF: consistent rows and no numbered repeating groups.
  • 2NF: non-key facts depend on the whole composite key.
  • 3NF: non-key facts do not depend on other non-key facts.
  • Denormalize: only with source of truth, sync, staleness and repair plans.
  • Surrogate id: does not replace a business UNIQUE constraint.
  • Primary key: unique, non-null chosen identity with a B-tree index.
  • Foreign key: protects relationships; index important child-side lookups.
  • CASCADE: use for owned components, not independent objects.
  • CHECK: passes true or null; pair with NOT NULL when required.
  • Unique nulls: distinct by default; use NULLS NOT DISTINCT when needed.
  • UUID: distributed identity, wider indexes; use the native type.
  • TIMESTAMPTZ: instant; TIMESTAMP: local wall-clock fields.
  • NUMERIC: exact decimal; DOUBLE: approximate measurement.
  • Array: bounded atomic collection, not a hidden relationship table.
  • Enum: small stable set; reference table for configurable metadata.
  • JSONB: flexible atomic data; keep core stable facts as columns.
  • GIN: broad containment; expression B-tree for typed scalar access.
  • Migrations: expand, backfill, validate, switch, then contract.

19. Official reference map

Last reviewed · July 2026 · part of knowledge-base