Schema Design Indexes and Query Access Patterns
Purpose and Outcome
Schema design is the act of shaping stored data so the system can answer its important questions correctly, quickly, and repeatedly. Index design is the companion act of choosing extra lookup structures that let the database find the relevant rows, documents, or keys without scanning everything. Query access patterns are the named ways the application reads and writes data: sign in by email, list recent orders for one customer, show unread notifications, or search inventory by warehouse and SKU.
In system design interviews and production architecture reviews, these three topics belong together. A schema that looks elegant in isolation can be poor if every critical query needs a table scan. An index can be technically valid but useless if its column order does not match the predicates and sort order. A query can seem simple while hiding a fan-out, hot partition, or consistency problem. The outcome of this lesson is a practical method: start from access patterns, choose entities and relationships, add indexes that serve those patterns, and know when to denormalize into read models.
How Storage Engines Use Schemas and Indexes
A relational schema usually declares tables, columns, types, nullability, primary keys, foreign keys, and uniqueness constraints. A document schema may be looser, but it still has shape: fields, embedded arrays, references, required attributes, and expected cardinality. A key-value or wide-column design may expose fewer formal constraints, yet the key structure is still a schema because it decides locality, partitioning, and lookup cost.
The primary key identifies a record and often determines physical or logical placement. In many relational engines, the primary key is backed by a B-tree. A B-tree stores sorted keys in pages. Searching descends from a root page through branch pages to leaf pages, giving logarithmic lookup cost and efficient range scans. A secondary index stores another ordered key plus a pointer to the base row, or in some engines a copy of the primary key. That means an indexed query can still require a second lookup unless the index covers every requested column.
Composite indexes contain multiple fields in a fixed order, such as customer_id then created_at. The leftmost prefix rule is the important mental model: an index on customer_id, created_at is useful for queries constrained by customer_id and optionally ordered or ranged by created_at. It is much less useful for a query that filters only on created_at, because the created_at values are grouped inside each customer_id value rather than globally sorted.
Query planners estimate whether to use an index by comparing selectivity and cost. Selectivity means how much the predicate narrows the candidate set. A status column with three values often has low selectivity; an email column has high selectivity. Cost includes reading index pages, reading table pages, sorting, joining, and returning rows. This is why an index is not a magic speed switch. It speeds some reads, slows some writes, consumes storage, and can be ignored if the planner believes a scan is cheaper.
Syntax and Design Anatomy
A useful schema design artifact names each access pattern before naming every table. For example: AP1: authenticate user by unique email; AP2: list newest orders for a customer; AP3: render a follower feed. For each pattern, record the equality filters, range filters, sort order, expected result count, consistency requirement, and write rate. That list gives you the anatomy of the indexes.
For a relational design, the anatomy is entity table, primary key, constraints, foreign keys, and secondary indexes. For AP1, users(id primary key, email unique, created_at, status) says that id is stable identity while email is a lookup key with uniqueness. For AP2, orders(id primary key, customer_id, created_at, total, status) needs an index starting with customer_id and continuing with created_at if the product shows recent orders per customer. For AP3, a normalized posts table may not be enough; a feed_entries table keyed by follower_id and created_at can store a read model.
The same reasoning applies outside SQL. In a document database, embedding line items inside an order makes sense when the dominant read is fetching one order with all its items. Referencing line items separately makes sense when items are updated independently, shared, or queried across orders. In a wide-column store, the partition key controls which node receives the query, and the clustering columns control order inside the partition. In a search engine, inverted indexes map terms to document identifiers, which is excellent for text search but not a substitute for transactional uniqueness.
Worked Example 1: User Lookup by Email
The first example is deliberately small. A user table needs a stable primary key for internal references and a unique email lookup for sign-in. The access pattern is equality on email with one expected row. A unique index enforces correctness and accelerates lookup. Without the uniqueness constraint, two accounts can share an email and the authentication service must invent ambiguous behavior.
# Example 1: validate a user table around known access patterns.
users = [
{"id": 1, "email": "ada@example.com", "created_at": "2026-01-03", "status": "active"},
{"id": 2, "email": "lin@example.com", "created_at": "2026-01-04", "status": "disabled"},
]
unique_email_index = {}
for row in users:
email = row["email"]
if email in unique_email_index:
raise ValueError(f"duplicate email: {email}")
unique_email_index[email] = row["id"]
print(unique_email_index["ada@example.com"])
The deterministic output is 1. The in-memory dictionary stands in for a unique index: each email maps to one user id, and duplicate insertion raises an error. In a real database, the unique index also becomes a concurrency control tool. Two simultaneous registrations for the same email cannot both commit if the database enforces the constraint atomically.
Worked Example 2: Recent Orders by Customer
The second example introduces composite index order. The application wants the newest orders for one customer. The equality predicate is customer_id; the range or sort dimension is created_at. Therefore customer_id belongs first, followed by created_at. That layout lets the engine jump to one customer’s contiguous key range and read it in timestamp order. If the index were created_at, customer_id, the engine could quickly find a time range but would still inspect rows for many customers.
# Example 2: show why a composite index order must match the query shape.
orders = [
{"customer_id": 7, "created_at": "2026-02-01T10:00:00Z", "total": 35},
{"customer_id": 7, "created_at": "2026-02-03T09:00:00Z", "total": 19},
{"customer_id": 9, "created_at": "2026-02-02T12:00:00Z", "total": 72},
]
index = sorted((o["customer_id"], o["created_at"], o) for o in orders)
recent_for_7 = [row for cid, created, row in index if cid == 7]
print([row["created_at"] for row in recent_for_7])
The output is ['2026-02-01T10:00:00Z', '2026-02-03T09:00:00Z']. This example sorts ascending; many databases can scan a B-tree backward to return newest first. If the query also returns only customer_id, created_at, and total, adding total to a covering index may avoid row lookups. The trade-off is write amplification: every order insert updates the base table and the secondary index, and a wider covering index occupies more cache and disk.
Worked Example 3: Follower Feed Read Model
The third example shows denormalization. A social feed query asks, “show posts visible to follower team-a.” A normalized model can store authors, followers, and posts, then join at read time. That is flexible, but when a popular follower opens the feed often, repeated joins and sorting become expensive. A feed_entries read model stores precomputed entries by follower, usually ordered by creation time or ranking score.
# Example 3: materialize a read model for a feed-style access pattern.
events = [
{"author": "ada", "follower": "team-a", "post_id": 101},
{"author": "ada", "follower": "team-b", "post_id": 101},
{"author": "lin", "follower": "team-a", "post_id": 202},
]
feed_by_follower = {}
for event in events:
feed_by_follower.setdefault(event["follower"], []).append(event["post_id"])
print(feed_by_follower["team-a"])
The output is [101, 202]. The write path now fans out each post event to follower-specific feed entries. Reads become cheap because they fetch one follower’s prebuilt list. The cost moves to writes, storage, and consistency. If fan-out fails halfway, some followers see missing entries until repair jobs replay events. This is a classic system design trade-off: optimize the dominant read by accepting more complex writes.
Design Choices and Trade-Offs
Normalization reduces duplication and keeps updates local. It is a strong default for transactional systems with rich relationships and ad hoc reporting. Denormalization duplicates data to match a high-volume access pattern. It is useful for feeds, dashboards, counters, search projections, and time-series rollups, but each duplicate copy needs a refresh path and a correction strategy.
Surrogate keys, such as generated ids, are stable and compact. Natural keys, such as email or SKU, carry business meaning but can change. A common compromise is a surrogate primary key plus a unique constraint on the natural key. That lets foreign keys remain stable while preserving business uniqueness. For distributed stores, key choice also affects partition balance. Monotonic keys can concentrate writes at the newest range in some storage engines; random or hashed keys spread writes but make range scans harder.
Index count is another trade-off. More indexes create more possible fast reads, but they slow inserts, updates, and deletes. They also increase backup size and recovery work. A good index portfolio is tied to named queries, not guesswork. Drop indexes that are unused after observing query plans and workload metrics, but do so carefully because a rarely used index may protect an expensive administrative or incident response query.
Failure Modes and Troubleshooting
Symptom: a query is slow after data growth. The cause is often a missing index or a low-selectivity index. Diagnose with the query plan, row estimates, actual rows read, and whether a sort or full scan appears. Correct by adding an index whose leading columns match equality predicates, then range and sort needs. Verify with representative data, because tiny development datasets hide planner behavior.
Symptom: writes became slower after adding several indexes. The cause is write amplification. Every insert or update must maintain each affected index, and random index writes can pressure disk and cache. Diagnose by comparing write latency, lock waits, and index size before and after the change. Correct by removing redundant indexes, narrowing covering indexes, batching backfills, or separating read-heavy projections into asynchronous stores.
Symptom: the database ignores an index you expected it to use. The cause may be column order, stale statistics, a function wrapping the indexed column, mismatched types, or a predicate that returns too much of the table. Diagnose with an explain plan and statistics inspection. Correct by rewriting the predicate, refreshing statistics, using the correct type, or creating an expression index only when that expression is a stable access pattern.
Symptom: a hot partition or hot page appears. The cause is usually a key that sends many writes to one logical range, such as a tenant with extreme volume or a timestamp-only partition key. Diagnose by looking at per-partition throughput, queue depth, and latency. Correct by adding a higher-cardinality partition component, sharding the hot tenant, bucketing by time plus hash, or moving aggregate updates to an asynchronous path.
Security, Performance, and Reliability Implications
Schema and index choices affect security because constraints are part of enforcement. A unique email index prevents account ambiguity. Foreign keys prevent orphaned records where the database supports them. Row ownership fields such as tenant_id must be present in schemas and leading indexes for multi-tenant queries; otherwise authorization filters may be slow enough that engineers are tempted to bypass them in internal tools.
Performance depends on locality, selectivity, and result size. An index that finds ten rows is helpful; an index that finds half the table may add overhead. Pagination also matters. Offset pagination over deep pages can force the engine to walk and discard many rows. Keyset pagination, using the last seen indexed key such as created_at and id, is usually better for large ordered lists.
Reliability depends on migration discipline. Adding a non-null column, backfilling a large table, or building an index can lock resources if done carelessly. Safer migrations usually add nullable columns first, deploy code that writes both old and new fields, backfill in chunks, validate, then enforce constraints. Large index builds should be scheduled, throttled, or performed with online mechanisms supported by the database.
Hands-On Lab: Design Indexes from Access Patterns
Prerequisites: a text editor and Python 3. No external database is required; the lab uses small in-memory structures to practice the reasoning before applying it to a real engine.
- Write three access patterns for an order service: create an order, fetch an order by id, and list recent orders for one customer.
- Choose a schema with orders(id, customer_id, created_at, status, total). Mark id as the primary key.
- Add a secondary index mentally represented as (customer_id, created_at, id). Explain why customer_id is first.
- Run the second worked example and change the customer id filter from 7 to 9. Verification: the printed list contains only the timestamp for customer 9.
- Add a status filter to the access pattern. Decide whether the index should be (customer_id, status, created_at) or (status, customer_id, created_at). Verification: if the query always starts from one customer, customer_id should remain first; if an operations dashboard scans all pending orders across all customers, status may need a separate index.
- Cleanup: remove the temporary script or notes. In a real database lab, rollback would mean dropping experimental indexes after confirming they are not used by production queries.
Assessment Exercises
- A table has an index on (created_at, customer_id), but the main query is WHERE customer_id = ? ORDER BY created_at DESC LIMIT 20. Explain the mismatch and propose a better index.
- You are designing product search with filters for category, price range, and free-text description. Which part belongs in a transactional database index, and which part likely needs a search index?
- A feed service can compute timelines at read time or fan out posts at write time. Describe the read latency, write cost, storage, and repair implications of both choices.
- An index on status is not used for a query where status = ‘active’ and 92 percent of rows are active. Explain why a planner may prefer a scan.
- Design a migration sequence for adding a required tenant_id column to a large shared table without a long write outage.
Summary
Schema design, indexes, and access patterns form one design loop. Start by naming the operations the system must serve, including filters, sort order, cardinality, and consistency. Choose schemas that preserve identity and relationships. Add indexes whose leading fields match the way queries narrow data. Denormalize only when a named access pattern justifies the write, storage, and repair cost. This is the storage-level reasoning that turns system design from boxes and arrows into a service that can actually answer its workload.
