1. Courses
  2. System Design
  3. SQL & Data Modeling
  4. Stop 1

35 minBeginner

Modeling data in tables

Good tables make every later feature easier. Bad tables turn every report into an argument about which number is right. This first lesson of SQL & Data Modeling shows you how to split real grocery data into tables that keep each fact in one place, and how to write them in SQL and in Prisma. You need no database experience to start.

By the end you can

  • Turn a messy sales sheet into clean tables that point to each other with keys
  • Pick the right column type for money, time, quantities and ids
  • Model one-to-many and many-to-many links, including a junction table
  • Write the DDL and the matching Prisma schema, and run the first migration
  • Decide when to break the rules on purpose: copied totals, soft delete, enums

The everyday picture

Think of a shopkeeper's ledger book on the counter (in South Asia it is called a khata). On every line he writes the date, the item, the amount, and the supplier's full name, phone number and address. After a year he has 3,000 lines. Then the supplier moves to a new warehouse. Now he must find and fix every line with the old address. He misses some. Next month his son reads the book and finds two different addresses for the same supplier. Which one is true?

A careful shopkeeper does it differently. He keeps one page at the back for suppliers. Each supplier gets a number and one line with the name, phone and address. On the sales pages he only writes "Supplier 14". When the address changes, he fixes one line, and every sale page is still correct.

That number is a key, the back page is a table, and "write each fact in one place, refer to it by number" is most of what data modeling means. The rest of this lesson is the detail.

Tables, rows and the right types

A table holds one kind of thing: stores, products, sales. Each row is one of those things, and each column is one fact about it. Every column has a type, and Postgres checks every value against it. A wrong value is rejected at the door, before it can spoil a report. So choosing the type is your first design decision, and the cheapest one to get right.

Two types cause most of the trouble in shop and payment systems: money and time.

Money goes in integer cents, never in a float. A column cannot hold 0.1 exactly, so small errors appear and add up over thousands of receipts. Whole numbers have no such problem:

SQL
SELECT 0.1::float8 + 0.2::float8 AS as_float,
       0.1::numeric + 0.2::numeric AS as_numeric,
       10 + 20                     AS as_cents;
Output
      as_float       | as_numeric | as_cents
---------------------+------------+----------
 0.30000000000000004 |        0.3 |       30

Mangrove Market stores every amount as a whole number of cents: a bag of rice at $6.50 is 650 in a column named unit_price_cents. Integers are exact and fast, and they arrive in JavaScript as plain numbers that add up correctly. You turn cents into dollars only when you show the number. A Postgres integer holds up to about 21 million dollars in cents, plenty for one price or one receipt, and sum() over an integer column returns a bigint, so a total over millions of receipts does not overflow.

The other exact choice is numeric(12,2): up to 12 digits, 2 after the decimal point. It is a fine type, and many teams use it. The catch is in your app: drivers hand numeric values to JavaScript as strings or as a decimal object (Prisma uses Prisma.Decimal), so every calculation needs a decimal library. Pick one convention for the whole company and keep it. This course uses cents.

Moments go in timestamptz. A timestamptz value is one exact moment. Postgres stores it in UTC and shows it in your session's time zone. A sale at 21:30 in a store at UTC+6 is 15:30 UTC, and both displays mean the same moment. The older type, timestamp without time zone, stores only the clock reading. If one server writes local time and another writes UTC, the database cannot tell them apart, and your "sales by hour" chart shifts by six hours for half the stores. The examples in this course use the zone name 'Asia/Dhaka' (UTC+6) in code; put your own there, such as 'Africa/Lagos' or 'Asia/Jakarta'.

For the business day of a sale, add a separate date column. A receipt at 00:15 after a late festival night may belong to the previous trading day, and that is a business rule, not a clock fact.

FactUseAvoidWhy
Price, total, discountinteger cents (or numeric(12,2))real, double precision, textExact maths; text cannot be summed or checked
When something happenedtimestamptztimestampWithout a zone, local and UTC times get mixed up
Business daydateA string like '06/10/26'Dates sort and compare correctly; strings do not
Quantityintegernumeric for whole unitsSmaller and faster; use numeric(10,3) for loose kg
Idbigintintegerinteger stops at about 2.1 billion
Names, codestextVery short varchar(n)Same speed in Postgres; add a CHECK if length matters
Yes / noboolean'Y' / 'N' textOnly true, false or NULL can get in

Quick check

A can of beans costs $1.25. What does Mangrove store in unit_price_cents?

Primary keys: the number that never changes

A is the row's identity. Other tables will store it, so it must be unique, never empty, and it should never change.

It is tempting to use a business code as the key, such as the store code 'S027' or the barcode. These are , and they change more often than people expect. Regions get re-coded. A supplier reuses a barcode for a new pack size. If 'S027' is stored in ten million sale rows, renaming it means rewriting all of them. The safe pattern is a : a meaningless id as the primary key, plus the business code in its own column with a UNIQUE constraint. The code can then change in one row.

For the id itself you have two families:

  • bigint identity. Postgres hands out 1, 2, 3 and so on. It is small (8 bytes), easy to read on a support call, and new rows always go to the end of the index. The downsides: only the database can create one, so an offline handheld scanner cannot, and the numbers are easy to guess in a URL.
  • . 16 bytes, and any device can create one without asking the server, which is what offline-first apps need (Offline-first apps and data sync). Hard to guess. A random UUID (version 4, gen_random_uuid() in Postgres) has a cost, shown below. A time-ordered UUID (version 7, or the similar ULID format) avoids most of that cost. Postgres 16 has no built-in v7 function (Postgres 18 adds uuidv7()), so on 16 you create v7 ids in the app; recent Prisma versions support @default(uuid(7)).
Ordered ids (bigint, UUIDv7) p1 p2 p3 p4 p5 p6 new ids 1001, 1002, 1003 all go to the last page Random ids (UUIDv4) p1 p2 p3 p4 p5 p6 new ids 9f3c…, 1a7e…, c40b… each insert touches a different page
An index keeps ids in sorted order across many pages. Ordered ids always land on the newest page, which stays in memory. Random UUIDs land anywhere, so Postgres must keep far more pages in memory and splits pages more often as the table grows.

Quick check

Head office plans to re-code every store by region next year. What should the primary key of site be?

Relationships: how tables point at each other

A is a column that holds another table's id. sale.site_id holds the id of the store where the sale happened. Postgres checks that the store exists, so a sale can never point at a site that is not there.

In this course, two table names come from the retail world, so here they are once. A site is any place that holds stock: a store, a partner shop or a distribution centre (DC). An article is one product, one SKU, such as "Rice 5kg". There are three shapes of link:

  • One-to-many (1:N). One store has many sales; each sale belongs to one store. The "many" side holds the foreign key: sale.site_id. This is the most common shape by far.
  • Many-to-many (N:M). An area manager covers many stores, and a store can have several managers. Neither table can hold a single foreign key, so you add a third table, a , staff_site(staff_id, site_id), with one row per pair. The stock table is also a junction (site × article) that carries its own fact, the quantity.
  • One-to-one (1:1). Rare. Use it to split off optional or rarely read data, such as a store's lease details. The second table's primary key is also its foreign key, so there can be at most one row per store.
site id PK site_code UQ name, format article id PK sku UQ unit_price_cents stock site × article site_id PK, FK article_id PK, FK qty CHECK ≥ 0 sale id PK site_id FK sale_date total_cents sale_line sale_id PK, FK article_id FK qty unit_price_cents 1 N 1 N 1 N 1 N 1 N
Each line is a foreign key. The table at the N end stores the id of the table at the 1 end. The two master tables (highlighted) are what everything else points to. stock is a junction table with one row per site and article.

A foreign key also decides what happens when someone deletes the parent row. The default refuses the delete while child rows exist, which is right for stores and products: you must never lose sales history because someone removed a store. ON DELETE CASCADE deletes the children too. Use it only where the children have no meaning alone, such as the lines of a receipt.

Quick check

One store has many sales. Where does the foreign key go?

Normalization: every fact in one place

is the ledger lesson written as rules. Textbooks list many "normal forms". In daily work you need the first three, and each one fixes a kind of mess you have seen in spreadsheets.

  • First normal form (1NF): one value per cell. A column like "items: Rice x2, Oil x1" holds a list. You cannot sum it, index it or check it. Split it into one row per item.
  • Second normal form (2NF): each column depends on the whole key. A receipt line is identified by (receipt, line number). The store name depends only on the receipt, not on the line, so it belongs on the receipt, not repeated on every line.
  • Third normal form (3NF): no column depends on another non-key column. The store address depends on the store, not on the sale. It belongs in the store table.

In one sentence: each fact lives in exactly one row of one table, and everything else refers to it by id.

Before: one sales sheet receipt store items R-101 12 Hill Road Rice x2, Oil x1 R-102 12 Hill Rd. Rice x1 R-103 12 Hil Road Oil x3, Dal x2 one store, typed three ways a list inside one cell normalize After: each fact in one place site 1 12 Hill Road article 7 Rice 5kg sale R-101 site 1 sale_line R-101 art 7 x2 site_id sale_id article_id
Top: the store is typed by hand on every receipt, so copies drift, and items are crammed into one cell. Bottom: the store and the article each live in one row, and receipts point at them by id. Fixing the address now means changing one row.

One copy is not a mistake. sale_line.unit_price_cents looks like a duplicate of article.unit_price_cents, but it records a different fact: the price at the moment of sale. Tomorrow's price change must not rewrite yesterday's receipts. When a value describes a moment in history, store it with the history.

Think first: sale.total_cents can be computed from its lines. Why store it at all?

Because it is read far more often than it is written. Dashboards sum totals over millions of receipts, and the POS prints the total on the receipt. Computing it from the lines every time means a join and a sum per receipt. The copy is safe here because a receipt is written once, in one transaction with its lines, and never edited afterwards. If receipts could be edited, every edit would have to update the total in the same transaction. That is denormalization on purpose: a copy with a clear reason and a clear way to stay correct.

Quick check

A partner_order row has a column products = 'rice:2, oil:1'. Which rule does it break first?

Rules you write once: constraints, audit columns, soft delete, enums

Constraints are the last line of defence. Your NestJS DTO checks that a quantity is positive, but only for requests that pass through that endpoint. A data-fix script, a second service, or someone in psql at midnight skips it. A in the database applies to every writer, forever. Use NOT NULL unless empty truly makes sense, UNIQUE on every business code, CHECK for simple rules like qty >= 0, and foreign keys for every link.

Audit columns answer "when did this change?" during an incident. Give each table created_at and updated_at. Prisma's @updatedAt is filled in by Prisma Client, so an UPDATE from psql or another service leaves it unchanged. A small trigger (step 4 below) covers every writer.

Soft delete means marking a row as gone (is_active = false or a deleted_at time) instead of removing it. It keeps history and makes undo easy. It also has costs: every query must remember to filter, and a UNIQUE code now clashes with its own deleted copy. A partial unique index fixes that (Indexes from the inside). A good default: deactivate master rows that history points to, such as a closed store or a discontinued product, and really delete rows that nothing references.

Enums or lookup tables. A Postgres is a fixed list inside the schema. Adding a value is one quick ALTER TYPE … ADD VALUE, but you cannot remove one, and every change needs a migration. A lookup table, such as category, is ordinary rows: the business can add one from an admin screen, and it can carry extra columns like a label in a second language, a sort order or an active flag. Use an enum when your code branches on the value: a mobile-wallet payment (M-Pesa, UPI, bKash and similar) runs a different flow from cash. Use a lookup table for lists that people manage.

Quick check

The buying team adds new product categories most months and wants to do it from an admin screen. Enum or lookup table?

Build it

We will build the five core tables by hand in SQL, watch the constraints work, and then build the same tables with Prisma. No Docker yet? The Practice section below runs real PostgreSQL in your browser, so you can try the constraint rules there first.

  1. Start a practice Postgres 16

    Docker is the quickest way. The database runs on your machine only.

    Terminal
    docker run --name pg16 -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:16
    docker exec -it pg16 createdb -U postgres shop
    docker exec -it pg16 psql -U postgres -d shop   # opens a SQL prompt
  2. Create the master tables

    Enums for values the code branches on, a lookup table for categories, surrogate ids, business codes as UNIQUE columns, and money in cents.

    SQL
    CREATE TYPE site_format AS ENUM ('STORE', 'PARTNER', 'DC');
    CREATE TYPE pay_method  AS ENUM ('CASH', 'CARD', 'WALLET');
    
    CREATE TABLE site (
      id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      site_code   text        NOT NULL UNIQUE,          -- the code people say: 'S027'
      name        text        NOT NULL,
      format      site_format NOT NULL,
      city        text        NOT NULL,
      is_active   boolean     NOT NULL DEFAULT true,    -- closed stores keep history
      created_at  timestamptz NOT NULL DEFAULT now(),
      updated_at  timestamptz NOT NULL DEFAULT now()
    );
    
    CREATE TABLE category (                             -- a lookup table
      id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      name  text NOT NULL UNIQUE
    );
    
    CREATE TABLE article (
      id                bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      sku               text        NOT NULL UNIQUE,    -- barcode on the pack
      name              text        NOT NULL,
      category_id       bigint      NOT NULL REFERENCES category (id),
      unit_price_cents  integer     NOT NULL CHECK (unit_price_cents >= 0),  -- 650 = $6.50
      is_active         boolean     NOT NULL DEFAULT true,
      created_at        timestamptz NOT NULL DEFAULT now(),
      updated_at        timestamptz NOT NULL DEFAULT now()
    );
  3. Create stock, sale and sale_line

    Composite primary keys for the junction and line tables, a UNIQUE rule that blocks duplicate receipt uploads, and indexes on the foreign key columns.

    SQL
    CREATE TABLE stock (
      site_id     bigint      NOT NULL REFERENCES site (id),
      article_id  bigint      NOT NULL REFERENCES article (id),
      qty         integer     NOT NULL DEFAULT 0 CHECK (qty >= 0),
      updated_at  timestamptz NOT NULL DEFAULT now(),
      PRIMARY KEY (site_id, article_id)            -- one row per site per article
    );
    CREATE INDEX stock_article_id_idx ON stock (article_id);
    
    CREATE TABLE sale (
      id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      site_id       bigint      NOT NULL REFERENCES site (id),
      local_txn_id  text        NOT NULL,        -- receipt number from the POS
      sale_date     date        NOT NULL,        -- business day in the store's zone
      sold_at       timestamptz NOT NULL DEFAULT now(),
      total_cents   integer     NOT NULL CHECK (total_cents >= 0),
      pay_method    pay_method  NOT NULL,
      UNIQUE (site_id, local_txn_id)             -- a retried upload cannot insert twice
    );
    CREATE INDEX sale_site_id_sale_date_idx ON sale (site_id, sale_date);
    
    CREATE TABLE sale_line (
      sale_id           bigint   NOT NULL REFERENCES sale (id) ON DELETE CASCADE,
      line_no           smallint NOT NULL,
      article_id        bigint   NOT NULL REFERENCES article (id),
      qty               integer  NOT NULL CHECK (qty > 0),
      unit_price_cents  integer  NOT NULL,       -- price at the moment of sale
      PRIMARY KEY (sale_id, line_no)
    );
    CREATE INDEX sale_line_article_id_idx ON sale_line (article_id);
  4. Keep updated_at honest with a trigger

    This runs inside the database, so it works for Prisma, psql and any other writer. Repeat the CREATE TRIGGER for each table.

    SQL
    CREATE FUNCTION set_updated_at() RETURNS trigger
    LANGUAGE plpgsql AS $$
    BEGIN
      NEW.updated_at := now();
      RETURN NEW;
    END $$;
    
    CREATE TRIGGER site_set_updated_at
      BEFORE UPDATE ON site
      FOR EACH ROW EXECUTE FUNCTION set_updated_at();
  5. Try to break the rules

    Insert one store, one category and one article, then try three bad writes. Each one is stopped with a clear message that names the constraint.

    SQL
    INSERT INTO site (site_code, name, format, city) VALUES ('S001', 'Riverside', 'STORE', 'Bayside');
    INSERT INTO category (name) VALUES ('Grocery');
    INSERT INTO article (sku, name, category_id, unit_price_cents) VALUES ('8901234500012', 'Rice 5kg', 1, 650);
    
    INSERT INTO stock (site_id, article_id, qty) VALUES (1, 1, -1);   -- negative stock
    INSERT INTO stock (site_id, article_id, qty) VALUES (999, 1, 5);  -- store does not exist
    INSERT INTO site (site_code, name, format, city) VALUES ('S001', 'Copy', 'STORE', 'Bayside');
    Output
    ERROR:  new row for relation "stock" violates check constraint "stock_qty_check"
    ERROR:  insert or update on table "stock" violates foreign key constraint "stock_site_id_fkey"
    DETAIL:  Key (site_id)=(999) is not present in table "site".
    ERROR:  duplicate key value violates unique constraint "site_site_code_key"
    DETAIL:  Key (site_code)=(S001) already exists.
  6. Write the same tables in Prisma

    In a fresh NestJS project, run npm i -D prisma, npm i @prisma/client and npx prisma init. Point DATABASE_URL in .env at a new empty database, for example shop_prisma. Models are PascalCase with camelCase fields; @map and @@map keep the real names snake_case.

    Prisma
    datasource db {
      provider = "postgresql"
      url      = env("DATABASE_URL")
    }
    generator client {
      provider = "prisma-client-js"
    }
    
    enum SiteFormat {
      STORE
      PARTNER
      DC
      @@map("site_format")
    }
    
    model Site {
      id        BigInt     @id @default(autoincrement())
      siteCode  String     @unique @map("site_code")
      name      String
      format    SiteFormat
      city      String
      isActive  Boolean    @default(true) @map("is_active")
      createdAt DateTime   @default(now()) @map("created_at") @db.Timestamptz(6)
      updatedAt DateTime   @updatedAt @map("updated_at") @db.Timestamptz(6)
      stock     Stock[]
      sales     Sale[]
    
      @@map("site")
    }
    
    model Category {
      id       BigInt    @id @default(autoincrement())
      name     String    @unique
      articles Article[]
    
      @@map("category")
    }
    Prisma
    model Article {
      id             BigInt     @id @default(autoincrement())
      sku            String     @unique
      name           String
      categoryId     BigInt     @map("category_id")
      unitPriceCents Int        @map("unit_price_cents")   // 650 means $6.50
      isActive       Boolean    @default(true) @map("is_active")
      createdAt      DateTime   @default(now()) @map("created_at") @db.Timestamptz(6)
      updatedAt      DateTime   @updatedAt @map("updated_at") @db.Timestamptz(6)
      category       Category   @relation(fields: [categoryId], references: [id])
      stock          Stock[]
      saleLines      SaleLine[]
    
      @@map("article")
    }
    
    model Stock {
      siteId    BigInt   @map("site_id")
      articleId BigInt   @map("article_id")
      qty       Int      @default(0)
      updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
      site      Site     @relation(fields: [siteId], references: [id])
      article   Article  @relation(fields: [articleId], references: [id])
    
      @@id([siteId, articleId])   // composite primary key
      @@index([articleId])        // foreign keys need their own index
      @@map("stock")
    }
    Prisma
    enum PayMethod {
      CASH
      CARD
      WALLET
      @@map("pay_method")
    }
    
    model Sale {
      id         BigInt     @id @default(autoincrement())
      siteId     BigInt     @map("site_id")
      localTxnId String     @map("local_txn_id")
      saleDate   DateTime   @map("sale_date") @db.Date
      soldAt     DateTime   @default(now()) @map("sold_at") @db.Timestamptz(6)
      totalCents Int        @map("total_cents")
      payMethod  PayMethod  @map("pay_method")
      site       Site       @relation(fields: [siteId], references: [id])
      lines      SaleLine[]
    
      @@unique([siteId, localTxnId])
      @@index([siteId, saleDate])
      @@map("sale")
    }
    
    model SaleLine {
      saleId         BigInt  @map("sale_id")
      lineNo         Int     @map("line_no") @db.SmallInt
      articleId      BigInt  @map("article_id")
      qty            Int
      unitPriceCents Int     @map("unit_price_cents")
      sale           Sale    @relation(fields: [saleId], references: [id], onDelete: Cascade)
      article        Article @relation(fields: [articleId], references: [id])
    
      @@id([saleId, lineNo])
      @@index([articleId])
      @@map("sale_line")
    }
  7. Create the migration, add what Prisma cannot say, and apply it

    A Prisma schema cannot express CHECK constraints. Create the migration file without applying it, add the checks at the end, then apply. Prisma writes BIGSERIAL for autoincrement(), an older form of identity that behaves the same for you.

    Terminal
    npx prisma migrate dev --create-only --name init   # writes prisma/migrations/<timestamp>_init/migration.sql
    # edit that file: append the CHECK constraints below
    npx prisma migrate dev                              # applies it and regenerates the client
    SQL
    -- appended to the end of migration.sql
    ALTER TABLE "article"   ADD CONSTRAINT article_unit_price_cents_check CHECK (unit_price_cents >= 0);
    ALTER TABLE "stock"     ADD CONSTRAINT stock_qty_check                CHECK (qty >= 0);
    ALTER TABLE "sale"      ADD CONSTRAINT sale_total_cents_check         CHECK (total_cents >= 0);
    ALTER TABLE "sale_line" ADD CONSTRAINT sale_line_qty_check            CHECK (qty > 0);

    Open migration.sql once and read it. It is the real contract with the database, and it should look like steps 2 and 3. Changing the schema without downtime covers how to change these tables later without taking the app down.

Bring it to your team

Use these questions when someone shows a new schema or a new column.

  • "Which table is the single source of truth for this fact, and which team owns it?"
  • "What is the primary key, and is there any way it could change? Where is the human code stored?"
  • "Are all money columns integer cents, and all time columns timestamptz?"
  • "Which rules does the database enforce, and which ones live only in the app?"
  • "This value is copied from another table. Is that on purpose, and what keeps the copy correct?"
  • "What happens to sales and GRN history when we close a store or stop selling a product?"

Red flags

  • Prices or totals stored as float or as text
  • A column holding a list, like site_ids = '3,8,12'
  • The same store name or address typed into several tables
  • No UNIQUE constraint on site codes or SKUs, so duplicates are possible
  • Prisma DateTime fields without @db.Timestamptz

Practice

Give the stock table its rules

The site and article tables already exist, with two stores and two products. Add the rules to stock so that the database itself refuses bad rows: one row per store and product, both ids must exist, and the quantity must be present and never negative. The last line runs six test writes (each one is undone after the test) and reports whether Postgres accepted or rejected it. A normal row must still be accepted.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- Add the rules to this table, then run. Keep the last line: it tries six writes.
CREATE TABLE stock (
  site_id     bigint,
  article_id  bigint,
  qty         integer
);

SELECT * FROM test_stock_rules();
Ctrl + Enter runs
Show a solution
SQL
CREATE TABLE stock (
  site_id     bigint  NOT NULL REFERENCES site (id),
  article_id  bigint  NOT NULL REFERENCES article (id),
  qty         integer NOT NULL DEFAULT 0 CHECK (qty >= 0),
  PRIMARY KEY (site_id, article_id)       -- one row per store per product
);

SELECT * FROM test_stock_rules();

Each rule stops one bad write: the CHECK stops negative stock, NOT NULL on qty stops an empty quantity, the two foreign keys stop unknown ids, and the composite primary key stops a second row for the same pair. A primary key also makes its columns NOT NULL.

Pass the exercise to finish this task.

Normalize a partner order sheet

Until last month, Mangrove's wholesale team tracked partner shop orders in one shared sheet. Prices are in cents.

ordershopshop_phoneitemsordered_at
PO-501Ravi's Corner Shop555-0101Rice 5kg x10 @610, Oil 1L x24 @29014 Oct 09:10
PO-502Ravi's Corner Shop555-0110Sugar 1kg x12 @14014 Oct 17:45
PO-503Sunrise Mini Mart555-0199Rice 5kg x5 @61015 Oct 08:05

Write down the tables you would create instead. For each one, give the primary key, the foreign keys, and the type of every money and time column. Then name the cell that holds a list and the fact that is typed more than once.

  • Orders and order lines are separate tables, with one line row per product
  • The shop's name and phone live once (in site or a partner_shop table), and orders point to it by id
  • Each line stores the unit price in integer cents at the time of the order
  • ordered_at is timestamptz
  • The order code (PO-501) is a UNIQUE column, not the primary key
  • You spotted the list in items and the two different phone numbers for Ravi's shop
Show a model answer
SQL
-- the shop lives once; partner shops are rows in site (format = 'PARTNER')
-- site (id PK, site_code UNIQUE, name, phone, format, ...)

CREATE TABLE partner_order (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_code  text        NOT NULL UNIQUE,              -- 'PO-501'
  site_id     bigint      NOT NULL REFERENCES site (id),
  ordered_at  timestamptz NOT NULL
);
CREATE INDEX partner_order_site_id_idx ON partner_order (site_id);

CREATE TABLE partner_order_line (
  order_id          bigint  NOT NULL REFERENCES partner_order (id) ON DELETE CASCADE,
  article_id        bigint  NOT NULL REFERENCES article (id),
  qty               integer NOT NULL CHECK (qty > 0),
  unit_price_cents  integer NOT NULL CHECK (unit_price_cents >= 0),  -- price when ordered
  PRIMARY KEY (order_id, article_id)
);

The items cell holds a list (a 1NF problem), so it becomes one partner_order_line row per product. Ravi's shop appears with two phone numbers because the phone is typed on every order; in the new design it is stored once on his shop's row, and fixing it means changing one value.

Check yourself

Which column type should hold a product's selling price at Mangrove?

sale_line has a unit_price_cents column, and article also has unit_price_cents. What should you do?

Each area manager covers several stores, and a store can have more than one manager. How should you store this?

Flashcards

Open my review

Key takeaways

  • Each fact lives in one row of one table; everything else refers to it by id.
  • Money is integer cents (or numeric, never float), moments are timestamptz, business days are date.
  • Use a surrogate id as the primary key and keep business codes in UNIQUE columns that are allowed to change.
  • Foreign keys and constraints protect the data from every writer, not only your API. Index foreign key columns yourself.
  • Copy data only on purpose, with a reason and a way to keep the copy correct, such as a receipt total written once.

Sources

Episode transcriptThe ledger and the back page · about 14 minutes · tap any line to play from there
  1. NadiaImran, have you ever seen one supplier appear with two different addresses in the same report?
  2. ImranMore often than I'd like. Usually someone fixed the address in one place and not in another. Then two teams argue about which report is right.
  3. NadiaToday's lesson is about stopping that argument before it starts. It's the first episode of SQL and Data Modeling, so you need no database experience. We begin with how to split real grocery data into tables.
  4. NadiaThe lesson starts with a shopkeeper's ledger book on the counter. In South Asia it's called a khata. On every line he writes the date, the item, the amount, and the supplier's full name, phone number and address.
  5. ImranAfter a year, that's a lot of lines.
  6. NadiaThree thousand. Then the supplier moves to a new warehouse. He has to find and fix every line with the old address, and he misses some. Next month his son finds two addresses for the same supplier. Which is true?
  7. ImranNobody knows. That's exactly our report argument.
  8. NadiaA careful shopkeeper keeps one page at the back for suppliers. Each supplier gets a number and one line with the name, phone and address. On the sales pages, he only writes "Supplier fourteen".
  9. ImranSo when the address changes, he fixes one line, and every sales page is still correct.
  10. NadiaThat number is a key, and the back page is a table. "Write each fact in one place, refer to it by number" is most of data modeling.
  11. ImranSo a table is one kind of thing, a row is one of those things, and a column is one fact.
  12. NadiaAnd every column has a type, which Postgres checks on every value. Choosing types is your first design decision, and the cheapest one to get right.
  13. ImranWhich types cause the most trouble in a shop?
  14. NadiaMoney and time. For money, never use a floating-point type. A float can't hold point one exactly. In Postgres, point one plus point two as floats gives point three, followed by a long tail of zeros and a four.
  15. ImranTiny, but over thousands of receipts it adds up.
  16. NadiaSo at Mangrove we store whole cents in an integer column. A bag of rice at six dollars fifty is stored as six hundred and fifty, and you turn it into dollars only when you show it.
  17. ImranI've also seen numeric with two decimal places. Is that wrong?
  18. NadiaNo, it's exact too. The catch is in the app: JavaScript gets numeric back as a string or a decimal object, so every sum needs a decimal library. Integers just add up. Pick one convention and keep it.
  19. ImranAnd time?
  20. NadiaUse timestamptz, timestamp with time zone. It stores one exact moment. A sale at nine thirty pm in a store six hours ahead of UTC is three thirty pm UTC, and both displays mean the same moment.
  21. NadiaThe older type, timestamp without time zone, stores only the clock reading. If one server writes local time and another writes UTC, the database can't tell them apart. Your sales by hour chart shifts by six hours for half the stores.
  22. NadiaAnd for the business day of a sale, add a separate date column.
  23. ImranWhy separate? The date is inside the timestamp.
  24. NadiaBecause a receipt at twelve fifteen after a late festival night may belong to the previous trading day. That's a business rule, not a clock fact.
  25. ImranFair. Now keys. Every store already has a code, like S zero two seven. Why not use that as the primary key?
  26. NadiaThat's the natural guess, and the lesson warns against it. Business codes change more often than people expect. Regions get re-coded. A supplier reuses a barcode for a new pack size.
  27. NadiaIf S zero two seven is stored in ten million sale rows, renaming it means rewriting all of them. The safe pattern is a surrogate key: a meaningless id as the primary key, plus the business code in its own column with a unique rule.
  28. ImranSo the code can change in one row, just like the shopkeeper's back page.
  29. NadiaExactly. For the id itself, there are two families. A bigint identity, where Postgres hands out one, two, three. Small and easy to read, but an offline handheld scanner can't create one.
  30. NadiaOr a UUID, a long random-looking value that any device can create without asking the server. That's what offline-first apps need. But a fully random UUID has a cost.
  31. NadiaIf you can see your screen, the first diagram shows two rows of six index pages. On top, new ordered ids all land on the last page. Below, new random ids scatter across every page.
  32. ImranWhy does scattering matter?
  33. NadiaThe index keeps ids sorted. Ordered ids always land on the newest page, which stays in memory. Random ones land anywhere, so far more pages must stay in memory.
  34. NadiaA time-ordered UUID, called version seven, avoids most of that. Postgres sixteen has no built-in function for it, so you create them in the app. Postgres eighteen adds one.
  35. ImranSo my rule: bigint for tables only the server writes. UUID version seven when devices create rows offline, or when ids appear in public links.
  36. ImranNow relationships. How do tables point at each other?
  37. NadiaWith a foreign key: a column that holds another table's id. The sale table has a site id column, holding the id of the store where the sale happened. And Postgres checks that the store exists.
  38. NadiaTwo names in this course come from retail. A site is any place that holds stock: a store, a partner shop or a distribution centre. An article is one product.
  39. NadiaThere are three shapes of link. One-to-many is the most common. One store has many sales, and each sale belongs to one store. The many side holds the foreign key.
  40. ImranThen many-to-many. An area manager covers several stores, and a store can have more than one manager.
  41. NadiaNeither table can hold a single foreign key, so you add a third table, a junction table. Staff site, with a staff id and a site id, one row per pair.
  42. NadiaThe stock table is also a junction, site times article, carrying its own fact: the quantity.
  43. NadiaThe second diagram shows the core tables. The two master tables, site and article, are highlighted at the top. Stock links them. Sale belongs to a site, and sale line links a sale to an article. Every line in it is a foreign key.
  44. ImranWhat happens if someone deletes a store that has sales?
  45. NadiaBy default, Postgres refuses while child rows exist. That's right: you must never lose sales history because someone removed a store. Cascade delete, which removes the children too, is only for things like the lines of a receipt.
  46. NadiaAnd one surprise: Postgres indexes every primary key and unique rule for you, but not foreign key columns. Prisma doesn't add one either. Without it, joins get slow, and deleting one article scans the whole sale line table.
  47. ImranNow the big word. Normalization.
  48. NadiaIt's the ledger lesson written as rules. You need three. First normal form: one value per cell. A column like "Rice times two, Oil times one" holds a list. You can't sum it or check it. Split it into one row per item.
  49. NadiaSecond: each column depends on the whole key. The store name depends on the receipt, not on each line, so it belongs on the receipt. Third: no column depends on another non-key column. The store address depends on the store, so it lives in the store table.
  50. NadiaThe third diagram shows a before and after. On top, a sales sheet where one store's address is typed three different ways, with a spelling mistake in one, and items crammed into one cell. Below, four small tables linked by ids.
  51. ImranBut wait. Sale line has a unit price column, and the article table has a unit price too. Shouldn't we remove it and join?
  52. NadiaFair question, Imran. That column records a different fact: the price at the moment of sale. Tomorrow's price change mustn't rewrite yesterday's receipts. When a value describes a moment in history, store it with the history.
  53. ImranAnd the receipt total? It can be computed from the lines.
  54. NadiaIt's stored because it's read far more often than written. Dashboards sum totals over millions of receipts. And it's safe, because a receipt is written once, with its lines, in one transaction, and never edited. That's denormalization on purpose: a copy with a reason and a way to stay correct.
  55. ImranLast group. Constraints. We already validate in the NestJS DTOs.
  56. NadiaOnly for requests through that endpoint. A data-fix script or someone in psql at midnight skips it. A database constraint applies to every writer, forever: not null, unique codes, checks like quantity at least zero, and foreign keys.
  57. ImranAnd deleting stores we close?
  58. NadiaSoft delete. Mark master rows that history points to as inactive, like a closed store. Really delete rows nothing references.
  59. NadiaAnd for fixed lists, use an enum when your code branches on the value, like a wallet payment running a different flow from cash. Use a lookup table for lists people manage, like categories.
  60. ImranThis is close to home for me. At Mangrove, the site and article tables sit under almost everything my apps do.
  61. NadiaDeliveries, stock, sales and partner orders all carry a site id or an article id. The lesson tells how Ama's store, Riverside, was once added twice, the copy with a trailing space in its code. Unique didn't stop it, because the two strings differ.
  62. ImranAnd for three weeks half her sales went to the copy. A check on the code's format would have refused it.
  63. NadiaRight. Five rules carry most of the value in a redesign. One row per real thing. A surrogate id that never changes. The human code as a unique column that can change. Inactive instead of deletes. And a named owner team for each column.
  64. NadiaThe Build-it puts it into practice. You start Postgres sixteen in Docker, then create the master tables by hand: site and article with surrogate ids, unique codes, prices in cents, enums for site format and payment method, and a category lookup table.
  65. NadiaThen stock, sale and sale line, with a unique rule on site plus the POS receipt number, so a retried upload can't insert twice.
  66. ImranAnd then I try to break it.
  67. NadiaYes. You insert negative stock, stock for a store that doesn't exist, and a duplicate site code. Each one is stopped with a clear message that names the rule. Then you write the same tables in Prisma, and add the check constraints to the migration by hand, because Prisma's schema can't express them.
  68. NadiaNo Docker on your laptop? The practice section runs real Postgres in your browser. You give the stock table its rules, and six test writes show which bad rows still get through.
  69. ImranFor my next schema review. Which table is the single source of truth for this fact, and which team owns it?
  70. NadiaGood. Also: are money columns integer cents and time columns timestamptz? And which rules does the database enforce, and which live only in the app?
  71. NadiaOne to think about. Each area manager covers several stores, and a store can have more than one manager. How should you store it? Take a second.
  72. ImranA manager id column on the site table. Simple.
  73. NadiaThat allows only one manager per store. The right answer is the junction table, staff site, with both columns as the primary key. One row per pair, and the database can check both ids.
  74. ImranLet me sum up. One: each fact in one place, referenced by id, like the shopkeeper's back page. Two: money is whole cents, moments are timestamptz, business days are dates.
  75. ImranThree: a meaningless id as the key, with the store code in its own unique column that's allowed to change.
  76. NadiaAnd I'd add: copy data only on purpose. The price on a receipt line is a copy with a reason. A supplier address typed on every line is the ledger mistake.
  77. NadiaNext episode, we write the SQL you'll actually use against these tables, for real reports.
  78. ImranSee you there.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Schema design for a new feature

Tables, keys and constraints for a new feature, sample rows, and the queries the feature needs.

Open in the SQL Lab
Next stopSQL you'll actually use
Was this lesson helpful?
Questions and discussion

Loading the discussion…