Every schema decision on day one compounds at 100x data volume. This module covers the decisions that matter most.
Module 7 — Schema Design at Scale: Decisions That Cannot Be Undone
What this module covers: Schema decisions made on day one compound at 100x data volume. Choosing the wrong primary key type, the wrong partition strategy, or the wrong normalization level creates constraints that are expensive or impossible to reverse without downtime. This module covers the decisions that matter most — not as rules to follow, but as trade-offs to understand so you can derive the right answer for your specific workload.
Why Schema Decisions Are Different From Other Decisions
Most engineering decisions are reversible. You can refactor code, swap a library, change an API response shape, rewrite a service. The cost is development time.
Schema decisions in a production database are different. Once a table has 500 million rows:
- Changing a column type requires a full table rewrite (hours of downtime, or a complex online migration)
- Adding a
NOT NULLcolumn requires a default or a multi-step migration to avoid locking - Changing a primary key type (
INT→BIGINT) requires rewriting the table and every foreign key table - Repartitioning requires rebuilding the entire table and its indexes
- Removing normalization (collapsing two tables) requires a data migration and application changes across every service that touches both tables
The cost of reversing a bad schema decision grows linearly with data volume and quadratically with the number of services that depend on the schema. At 10 billion rows, some decisions cannot be reversed at all without extended downtime.
This is why schema design deserves disproportionate thought at the start.
Data Type Choices: The Foundation
Integer Primary Keys: INT vs BIGINT
INT (32-bit signed) holds values up to 2,147,483,647 (~2.1 billion). BIGINT (64-bit signed) holds up to 9,223,372,036,854,775,807 (~9.2 quintillion).
The INT vs BIGINT decision seems trivial. It has caused production outages at multiple large companies.
At 1,000 inserts/second, an INT primary key exhausts in:
2,147,483,647 / 1,000 / 86,400 / 365 ≈ 68 years
At 10,000 inserts/second:
2,147,483,647 / 10,000 / 86,400 / 365 ≈ 6.8 years
At 100,000 inserts/second (realistic for a blockchain indexer):
2,147,483,647 / 100,000 / 86,400 / 365 ≈ 249 days
When an INT sequence exhausts, inserts fail with:
ERROR: integer out of range
Migrating from INT to BIGINT on a 500M-row table with 8 foreign key tables is a multi-day operation requiring careful sequencing of table rewrites and application deployments.
Rule: always use BIGINT for primary keys. The 4-byte difference per row is irrelevant at any scale. The migration cost if you're wrong is catastrophic.
UUID vs BIGSERIAL: The Real Trade-off
UUIDs are attractive for distributed systems: generated client-side, globally unique without coordination, naturally partition-friendly. The cost is real and often underestimated.
UUIDs as primary keys on a B-tree index:
A UUID v4 is random. Inserting random UUIDs into a B-tree causes random page splits (Module 5) — every insert goes to a random leaf page instead of the rightmost page. This:
- Defeats the sequential-insert optimization in B-tree
- Causes all B-tree leaf pages to sit at ~50% fill factor
- Makes the primary key index 2x larger than it needs to be
- Increases write I/O: every insert touches a random cached page rather than the current append page
For a 500M-row table, the UUID primary key index might be 30GB instead of the 15GB it would be with BIGSERIAL. That's 15GB of extra cache pressure and 15GB of extra WAL on every full page write cycle.
UUID v7 (ordered): UUID v7 uses a time-based prefix, making them monotonically increasing. This restores the sequential-insert optimization. If you need UUIDs for distribution, use v7.
The practical guide:
| Use Case | Recommended Key |
|---|---|
| Single-database service, high write throughput | BIGSERIAL |
| Distributed system, client-generated IDs | UUID v7 |
| Multi-tenant where tenants generate IDs | UUID v7 |
| Public API (don't expose sequential IDs) | UUID v4 or v7 |
| Pure internal table, no external exposure | BIGSERIAL |
Numeric Types: Precision vs Storage
For a blockchain indexer storing transaction amounts in the native token's smallest unit (satoshis, wei, etc.), BIGINT is almost always better than NUMERIC — it's smaller, faster, and the conversion to display units happens in the application layer.
Text Storage: VARCHAR vs TEXT
In Postgres, VARCHAR(n) and TEXT are stored identically. VARCHAR(n) adds a constraint check on insert/update. TEXT has no length limit.
CHAR(n) is different — it pads values to length n with spaces. Almost never what you want. Avoid it.
BYTEA vs TEXT for Binary Data
Hashes, keys, and binary blobs should be stored as BYTEA, not hex strings in TEXT.
For a blockchain indexer with 100M transaction hashes, switching from TEXT to BYTEA saves 3.4GB in the column alone — plus proportional reduction in index size.
Normalization vs Denormalization: The Production Trade-off
When Normalization Wins
Third Normal Form (3NF) — every non-key column depends only on the primary key — produces:
- No data duplication (each fact stored once)
- Referential integrity enforced by the database
- Updates propagate automatically (change one row, reflected everywhere)
- Smaller tables (more cache-efficient for targeted queries)
Normalization is correct for:
- Frequently updated reference data (prices, statuses, user profiles)
- Data where consistency matters more than read speed
- OLTP workloads with many small, targeted queries
When Denormalization Wins
Denormalization — intentionally duplicating data to avoid joins — wins when:
- Join cost exceeds duplication cost
- The joined data is rarely or never updated
- The query is on a hot path where every millisecond matters
The cost of denormalization: if block_validator changes (rare in blockchain, common in other domains), you must update every transaction row for that block. If the data never changes after insert (append-only blockchain data), the cost is zero — denormalization is a pure win.
The practical rule: denormalize data that is:
- Written once, read many times
- Used together so frequently that joins are a consistent bottleneck
- Not subject to updates that would require cascading changes
Partial Denormalization: Materialized Views
When you need join performance without full denormalization, materialized views store the join result:
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion