Course topics

By WebNest Studio Engineering Team

Database Design Tutorial

Scalable, API-Oriented Database Design

A schema that will primarily be accessed through a REST or GraphQL API has design pressures beyond correctness and normalization: the shapes of data the API needs to serve efficiently, at scale, under real traffic, become a design input in their own right. A table can be perfectly modeled relationally and still make for a slow, chatty, or hard-to-paginate API if its structure doesn't anticipate how the API layer will actually read from it.

Designing for API consumption means thinking about pagination from the start, keeping the "hot path" — the handful of endpoints hit constantly, like a list or feed endpoint — free of expensive joins, and choosing keys and identifiers that work well as opaque values crossing a network boundary.

Why it matters

An API that lists records page by page looks fine with a hundred rows and collapses with ten million if the pages are fetched the naive way. Exposing sequential ids also tells competitors how many customers you have and invites people to guess other users' records. Both are decisions made in the schema.

Pagination-Friendly Keys

Offset-based pagination (LIMIT 20 OFFSET 1000) gets progressively slower as the offset grows, because the database still has to scan and discard all the skipped rows. Keyset (cursor-based) pagination — filtering on WHERE id > :last_seen_id ORDER BY id LIMIT 20 — stays fast regardless of how deep into the result set a client pages, because it uses an indexed column directly instead of counting through skipped rows. This means the schema should have a naturally ordered, indexed column (an auto-incrementing id, or a created_at plus a tiebreaker id) available for every list endpoint, since keyset pagination depends on it.

Avoiding Chatty Joins on Hot Paths

An endpoint like GET /orders/:id that a client calls constantly should be designed so the database can answer it with one efficient query, not a cascade of separate round trips or an expensive multi-table join chain executed on every single request. This is where the earlier denormalization discussion becomes directly relevant to API design: storing a computed order_total, or embedding a small, rarely-changing snapshot of data (like the customer's name at time of order) directly on the orders row, can turn a hot endpoint from a multi-join query into a single-table lookup.

Designing Identifiers for a Public API Boundary

An internal auto-incrementing integer primary key is efficient for the database but can leak information across an API boundary — sequential IDs let a client guess how many total rows exist or enumerate other users' resources by simply incrementing a number in a URL. A common pattern is keeping the efficient integer as the internal primary key for joins and indexing, while exposing a separate, non-guessable public identifier (a UUID or a short random string) in the API responses and URLs.

  • Keyset pagination: paginate on an indexed, ordered column (id or created_at + id), not OFFSET.
  • Hot-path denormalization: precompute or embed data on frequently-hit endpoints to avoid repeated joins per request.
  • Internal vs. public identifiers: keep an efficient internal integer PK, expose a separate UUID/public_id externally.

Example

A table designed for a paginated, public-facing API: keyset pagination and a separate public identifier

SQL
CREATE TABLE orders (
    id            BIGINT PRIMARY KEY,              -- internal, sequential, used for joins/indexes
    public_id     UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, -- exposed in the API/URLs
    customer_id   BIGINT NOT NULL REFERENCES customers(id),
    customer_name VARCHAR(150) NOT NULL,            -- snapshot, avoids a join on the hot read path
    order_total   DECIMAL(12, 2) NOT NULL,          -- precomputed, avoids aggregating line items
    created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_orders_created_at_id ON orders (created_at, id);

-- API list endpoint: GET /orders?after=2026-09-01T00:00:00Z&after_id=48291
SELECT public_id, customer_name, order_total, created_at
FROM orders
WHERE (created_at, id) > ('2026-09-01T00:00:00Z', 48291)
ORDER BY created_at, id
LIMIT 20;

-- API detail endpoint: GET /orders/{public_id} — single-table lookup,
-- no join needed to render the response.
SELECT public_id, customer_name, order_total, created_at
FROM orders WHERE public_id = 'b3f1...';
Output
The list endpoint stays equally fast on page 1 or page 1000, since it
seeks directly via the (created_at, id) index instead of scanning and
discarding skipped rows. The detail endpoint returns everything the API
response needs from a single table with no join. The URL exposes only
the random public_id, so clients cannot guess adjacent order IDs or
infer total order volume from a sequential integer.

Common Mistakes

  • Using OFFSET-based pagination on a large, frequently-paged table, causing steadily degrading response times as clients page deeper into results.
  • Exposing the internal sequential primary key directly in API URLs, letting clients enumerate or estimate the volume of other resources.
  • Designing a detail endpoint that requires three or four joins on every single request, instead of denormalizing the small set of fields that endpoint actually needs.
  • Forgetting to index the exact column(s) used for keyset pagination, which forces a full scan even though the query looks efficient.

Exercise

Rows have an internal numeric id and a separate public_id that the API exposes. Fetch the next page with keyset pagination: the client has already seen rows up to id 2, and the page size is 2. Return the id and public_id of the next page.

Expected output: id | public_id, 3 | c, 4 | d (one per line)

SQL · starter code
CREATE TABLE articles (id INTEGER PRIMARY KEY, public_id TEXT NOT NULL UNIQUE, title TEXT);
INSERT INTO articles (id, public_id, title) VALUES
  (1, 'a', 'One'), (2, 'b', 'Two'), (3, 'c', 'Three'), (4, 'd', 'Four'), (5, 'e', 'Five');

-- TODO: the next page of 2 rows after id 2, using keyset pagination (no OFFSET)
Show hints
  • Keyset pagination filters on the last value seen: WHERE id > 2.
  • Sort by the same column and take the page size with LIMIT 2.
Show solution
SQL · solution
CREATE TABLE articles (id INTEGER PRIMARY KEY, public_id TEXT NOT NULL UNIQUE, title TEXT);
INSERT INTO articles (id, public_id, title) VALUES
  (1, 'a', 'One'), (2, 'b', 'Two'), (3, 'c', 'Three'), (4, 'd', 'Four'), (5, 'e', 'Five');

SELECT id, public_id
FROM articles
WHERE id > 2
ORDER BY id
LIMIT 2;

Quick quiz

1. Why does OFFSET 1000000 LIMIT 20 become slow?

  • A. OFFSET is not supported on large tables
  • B. LIMIT must be larger than OFFSET
  • C. The database must read and discard a million rows before returning 20
  • D. It locks the table
Show answer

C. The cost grows with the page number.

2. How does keyset (cursor) pagination find the next page?

  • A. It filters on the last value seen, such as WHERE id > last_id, and uses the index to jump there
  • B. It counts all the rows
  • C. It caches every page
  • D. It uses a larger OFFSET
Show answer

A. Each page costs the same, however deep it is.

3. Why expose a random public identifier, such as a UUID, instead of a sequential id?

  • A. It is shorter
  • B. It sorts better
  • C. It is required by SQL
  • D. Sequential ids reveal how many records exist and make other records easy to guess
Show answer

D. The internal numeric key can still be used for joins.

Interview questions

Compare offset pagination with keyset pagination.

Show answer

Offset pagination, LIMIT n OFFSET m, is simple and lets a client jump to any page, but the database reads and throws away all the skipped rows, so deep pages are slow, and rows inserted or deleted between requests cause items to be repeated or missed. Keyset pagination filters on the last value seen and uses an index, so every page is equally fast and stable under changes, but the client can only move to the next or previous page.

What are the trade-offs of UUIDs compared with auto-increment integers as keys?

Show answer

Integers are small, fast to compare and index, and naturally ordered, but they reveal volumes, are guessable, and need a central generator. UUIDs can be generated anywhere without coordination and are not guessable, but they take 16 bytes, and fully random ones scatter inserts across the index, which hurts write performance. Time-ordered UUIDs, such as version 7, reduce that cost. A common design uses an integer internally and a UUID as the public identifier.

Key Points to Remember

  • Keyset (cursor-based) pagination on an indexed, ordered column stays fast at any depth, unlike OFFSET-based pagination which degrades as the offset grows.
  • Hot-path API endpoints benefit from deliberate denormalization (precomputed totals, embedded snapshots) to avoid repeated expensive joins per request.
  • Keep an efficient internal integer primary key for database joins and indexing, but expose a separate non-sequential public identifier (UUID) across the API boundary.
  • API-oriented design treats the application's actual endpoints and traffic patterns as first-class schema design inputs, not just an afterthought layered on top of a "correct" relational model.

Practice in the playground

Write and run your own code for this topic, or start from the exercise or an example. Runs your SQL on SQLite in your browser. Standard SQL works; a few features that exist only in PostgreSQL or MySQL do not. Your code for each topic is kept in this browser.

Run the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

These examples need a project environment on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.

WebNest Studio

Written by

WebNest Studio Engineering Team

Practical lessons from developers working with Java, Python, React, databases and production systems.

Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering