{"post":{"slug":"how-to-design-a-database-schema-2026","title":"How to design a database schema in 2026: structure, constraints, and tradeoffs","excerpt":"A schema designed under pressure becomes a schema you refactor for years. This guide covers relational design principles, naming conventions, and 2026 tradeoffs.","category":"code","date":"March 30, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">D</span>atabase schema decisions outlive almost every other architectural choice in a software project. Frameworks come and go, APIs get rewritten, but the tables you create today will be migrated around and worked with for years. Getting the foundation right — entities, constraints, naming, and indexes — saves far more time than clever application-layer abstractions.\n\n## What changed in 2026\n\n- **Postgres dominates** for relational workloads; MySQL/MariaDB still runs in legacy stacks but new projects mostly choose Postgres.\n- **`pgvector` is now a first-class extension** — AI-adjacent apps store embeddings in the same Postgres database as relational data, making schema design even more important.\n- **Schema-as-code** via Drizzle, Prisma, or Atlas is standard; raw SQL migrations are still the most portable format but ORM schema files are common.\n- **Multi-tenancy patterns** (row-level security, separate schemas per tenant) are well-established in Postgres; schema design must account for this early.\n- **Generated columns and partial indexes** in Postgres are widely used — worth knowing for performance-sensitive schemas.\n\n## Step 1 — identify entities and relationships\n\nBefore writing a single SQL statement:\n\n1. List the nouns in your domain (User, Order, Product, Invoice).\n2. Identify relationships (User places many Orders; Order contains many Products).\n3. Determine cardinality: one-to-one, one-to-many, many-to-many.\n4. Sketch an ERD (even on paper).\n\nMany-to-many relationships always need a junction table:\n\n```sql\n-- Users and Roles: many-to-many\nCREATE TABLE users (\n    id          BIGSERIAL PRIMARY KEY,\n    email       TEXT NOT NULL UNIQUE,\n    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE TABLE roles (\n    id   SERIAL PRIMARY KEY,\n    name TEXT NOT NULL UNIQUE\n);\n\nCREATE TABLE user_roles (\n    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n    role_id INT    NOT NULL REFERENCES roles(id) ON DELETE CASCADE,\n    PRIMARY KEY (user_id, role_id)\n);\n```\n\n## Naming conventions\n\n| Convention | Example | Notes |\n| --- | --- | --- |\n| Table names — plural nouns | `orders`, `line_items` | Consistent with most ORMs |\n| Column names — snake_case | `created_at`, `user_id` | Standard in Postgres ecosystem |\n| FK columns — `<table_singular>_id` | `user_id`, `order_id` | Unambiguous reference |\n| Boolean columns — `is_*` or `has_*` | `is_active`, `has_verified` | Clear intent |\n| Timestamp columns | `created_at`, `updated_at` | Always include both |\n\nPick one convention and enforce it across the entire schema.\n\n## Primary keys — surrogate vs natural\n\n| Type | Example | Pros | Cons |\n| --- | --- | --- | --- |\n| `BIGSERIAL` | 1, 2, 3… | Compact, fast index, readable | Sequential, guessable |\n| `UUID v4` | `f47ac10b-...` | Non-guessable, safe to expose | Larger (16 bytes), random index inserts |\n| `UUID v7` | time-ordered UUID | Non-guessable + sortable by time | Newer, less ORM support |\n| Natural key | email, slug | No extra column | Changes when business data changes |\n\nRecommendation in 2026: `UUID v7` for new schemas where you want a non-guessable sortable key; `BIGSERIAL` when compactness matters more than opacity.\n\n```sql\n-- Postgres 17+ UUID v7 (via extension)\nCREATE EXTENSION IF NOT EXISTS \"pg_uuidv7\";\n\nCREATE TABLE articles (\n    id         UUID PRIMARY KEY DEFAULT uuid_generate_v7(),\n    title      TEXT NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n```\n\n## Constraints — enforce data integrity at the DB level\n\n```sql\nCREATE TABLE orders (\n    id          BIGSERIAL PRIMARY KEY,\n    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,\n    status      TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),\n    total_cents BIGINT NOT NULL CHECK (total_cents >= 0),\n    placed_at   TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n```\n\n- `NOT NULL` everywhere data must exist — nullable columns demand null-checks throughout application code.\n- `CHECK` for enum-like fields; consider a Postgres `ENUM` type for truly static value sets.\n- `ON DELETE RESTRICT` vs `CASCADE`: RESTRICT prevents orphaning; CASCADE cleans up. Pick deliberately.\n\n## Indexing strategy\n\n```sql\n-- Always index FK columns (Postgres does NOT auto-index FKs)\nCREATE INDEX idx_orders_user_id ON orders(user_id);\n\n-- Composite index for common queries\nCREATE INDEX idx_orders_user_status ON orders(user_id, status);\n\n-- Partial index for active records only\nCREATE INDEX idx_users_active ON users(email) WHERE is_active = true;\n\n-- Index for text search\nCREATE INDEX idx_articles_title ON articles USING gin(to_tsvector('english', title));\n```\n\n## How to pick: normalize vs denormalize\n\n| Situation | Recommendation |\n| --- | --- |\n| OLTP (write-heavy, many small transactions) | Normalize to 3NF — reduce duplication |\n| Reporting / analytics queries | Denormalize or use a materialized view |\n| Rarely changing reference data | Lookup table (normalized) |\n| Data that is queried together always | Consider a single table with columns |\n| Frequently aggregated totals | Consider a denormalized counter column |\n\nStart normalized; denormalize only with a measured query to justify it.\n\n## Common mistakes\n\n**No `updated_at` column.** Add it from the start; retrofitting it means a migration on a large table.\n\n**Storing money as `FLOAT`.** Floating-point arithmetic introduces rounding errors. Store as `BIGINT` cents or use `NUMERIC(19,4)`.\n\n**Text columns with no length consideration.** `TEXT` is fine in Postgres (no performance cost vs `VARCHAR(n)`) — but if there is a business rule (email max length, slug max length), add a `CHECK` constraint.\n\n**Not indexing foreign keys.** In Postgres, a FK does not get an index automatically. Every unindexed FK is a latent table scan in production.\n\n**Over-indexing write-heavy tables.** Each index slows down inserts and updates. Profile before adding; indexes on columns never in WHERE/JOIN clauses waste space.\n\n## What to skip\n\n- **EAV (Entity-Attribute-Value) tables** for structured data — they are notoriously hard to query and maintain. Use JSONB columns for truly semi-structured data; use real columns for structured data.\n- **Varchar lengths as validation** in Postgres — `VARCHAR(255)` has no performance benefit over `TEXT`; validate length in application code or with a CHECK constraint.\n- **`created_by` as a username string** — reference the user's primary key; names change.\n\n## FAQ\n\n**Should I use UUIDs or integers for primary keys?**\nBoth are fine. UUIDs are better when you need to generate IDs outside the database (client-side, distributed systems). Integers are more compact and slightly faster. UUID v7 gives you time-ordering with opacity.\n\n**How many columns is too many for one table?**\nNo hard rule. If a subset of columns are always NULL for half the rows, that suggests a subtype that belongs in a separate table. If every column is used for every row, wide tables are fine.\n\n**When should I use JSONB columns?**\nFor genuinely semi-structured data where the schema varies per row — product attributes, user preferences, plugin configs. Do not use JSONB to avoid defining a schema you are too lazy to design.\n\n**How do I handle soft deletes?**\nAdd `deleted_at TIMESTAMPTZ` (null means not deleted) and use partial indexes plus RLS policies to filter them. Soft deletes complicate unique constraints — you may need partial unique indexes.\n\n## Where to go next\n\n- [How to normalize a database in 2026](/blog/how-to-normalize-a-database-2026)\n- [How to write SQL joins in 2026](/blog/how-to-write-sql-joins-2026)\n- [How to set up a database in 2026](/blog/how-to-set-up-a-database-2026)","hue":312,"metaTitle":"How to Design a Database Schema in 2026: Practical Guide","metaDescription":"Design a database schema in 2026 — entities, constraints, naming conventions, index strategy, and when to normalize vs denormalize. Practical guide.","keywords":["how to design a database schema 2026","database schema design principles","relational database design 2026","database normalization","database constraints best practices","erd diagram design","primary key surrogate key","database indexing strategy","postgres schema design 2026","database design tradeoffs"],"tldr":["**Start with entities and relationships** — name your tables as singular nouns and draw the ERD before writing DDL.","**Use surrogate primary keys** — UUIDs or bigint sequences; avoid using business data as PKs.","**Add constraints at the database level** — NOT NULL, UNIQUE, FOREIGN KEY, and CHECK constraints catch bad data that application code misses.","**Index foreign keys and query predicates** — unindexed FKs cause full table scans on every join.","**Skip:** storing JSON blobs for everything to \"stay flexible\" — schemas exist to enforce structure; a blob column is just deferred complexity."],"readTime":"5 min read","wordCount":1164},"related":[{"slug":"database-design-patterns-2026","title":"Database design patterns in 2026: practical patterns that scale","excerpt":"The database patterns that survive contact with real traffic are boring and well-known. Here is the 2026 practical guide — indexes, denormalization, soft deletes, audit trails, and the trade-offs.","category":"code","date":"February 13, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":152},{"slug":"what-is-a-database-schema-2026","title":"What Is a Database Schema in 2026: the blueprint for data","excerpt":"A database schema is the blueprint that defines how data is organized: the tables, columns, types, and relationships. Here is what a schema is and why it matters.","category":"code","date":"June 22, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":222},{"slug":"how-to-write-a-database-migration-2026","title":"How to write a database migration in 2026: safe, reversible, and zero-downtime","excerpt":"Database migrations fail when they lock tables in production or cannot be rolled back. This guide covers the 2026 patterns for safe, zero-downtime schema changes in Postgres.","category":"code","date":"April 6, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":305}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"how-to-descale-a-coffee-machine-2026","title":"How to descale a coffee machine in 2026: step-by-step for every machine type","excerpt":"Limescale kills heating elements and ruins extraction. Here is how to descale every major coffee machine type in 2026, with the right solution and cycle.","category":"guides","date":"March 30, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":95},"older":{"slug":"how-to-detect-ai-writing-2026","title":"How to detect AI writing in 2026: what works, what does not","excerpt":"AI detectors are less reliable than their confidence scores suggest. Here is what actually works for spotting AI writing in 2026 — and what the limits are.","category":"ai","date":"March 30, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":198}}