Module A-7·38 min read

Every schema decision on day one compounds at 100x data volume. This module covers the decisions that matter most.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

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 NULL column requires a default or a multi-step migration to avoid locking
  • Changing a primary key type (INTBIGINT) 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.

sql

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.

sql

The practical guide:

Use CaseRecommended Key
Single-database service, high write throughputBIGSERIAL
Distributed system, client-generated IDsUUID v7
Multi-tenant where tenants generate IDsUUID v7
Public API (don't expose sequential IDs)UUID v4 or v7
Pure internal table, no external exposureBIGSERIAL

Numeric Types: Precision vs Storage

sql

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.

sql

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.

sql

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
sql

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:

  1. Written once, read many times
  2. Used together so frequently that joins are a consistent bottleneck
  3. 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:

sql

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 & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.