Skip to content

Databases and data modelling

Transactions, indexes, normalisation, and choosing between relational and document stores.

01

ACID, stated plainly

beginner

Atomicity: all of a transaction happens, or none of it. Consistency: it moves the database between valid states, meaning the constraints you declared still hold afterwards. Isolation: concurrent transactions do not see each other's partial work. Durability: once committed, it survives a crash.

Three of those four are largely settled and you can rely on them without thinking. Isolation is where the detail lives, where the defaults differ between databases, and where the surprises are.

Most databases default to read committed rather than serialisable, because full isolation is expensive: the database must behave as though transactions ran one after another, and enforcing that costs either locks that others wait on or aborts that the application must retry. The default is a performance decision made on your behalf, and it is usually right and occasionally the cause of a bug nobody can reproduce.

What read committed permits is worth stating concretely. Two transactions read a balance of 100 into application memory, each subtracts 60, each writes 40, and the second write silently overwrites the first. Nothing errors. The same operation expressed as a single update statement is safe, because the row lock serialises it, so the bug lives entirely in the gap between reading and writing rather than in the database.

Durability also has a dial that people rarely look at. A commit is durable once the write-ahead log is flushed to disk, and most databases let you relax that: Postgres has synchronous_commit, MySQL has its flush setting, and turning either down makes writes markedly faster while putting the last fraction of a second at risk in a hard crash. That is a legitimate choice for analytics ingestion and a poor one for orders. The useful habit is to state the level you are running at and the anomalies it permits, rather than saying you use a relational database and therefore have ACID. The letters are a promise about a configuration, and the configuration has a default that nobody chose.

Check yourself

What does turning down synchronous commit actually trade away?

write 40write 40read committed permits itcomputed in the databaseno gap to interleaveTransaction Areads balance 100Transaction Breads balance 100Computed in the apptwo statementsOne UPDATEbalance = balance -60Lost updatesecond writeoverwritesRow lock serialisesboth applied
ClientServiceData storeDashed = asynchronousConsidered, not chosen
The gap between reading and writing is where the bug lives
Evidence · 2 sources
  1. 01
    PostgreSQL: transaction isolation

    That read committed is the default level and which anomalies it permits, including the lost update described here.

  2. 02
    MySQL: InnoDB transaction isolation levels

    That InnoDB defaults to repeatable read rather than read committed, so the two engines differ on identical code.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

02

Indexes and their cost

beginner

An index is a sorted structure, in nearly every case a B-tree, that turns a full table scan into a targeted lookup. On a large table that is the difference between reading a million pages and reading four, which is why the first index on a hot query is often a thousandfold improvement and the fifteenth is not.

Every index must be maintained on write, so each one slows inserts, updates and deletes and occupies disk and memory. An unused index is pure cost with no benefit, and most mature databases can tell you which of theirs have never been scanned. Removing those is the rare optimisation that improves writes without risking reads.

Composite indexes follow the prefix rule, which is the single most useful piece of indexing knowledge: an index on (a, b) serves a query on a, and on a and b together, and cannot seek on b alone. It is a phone book ordered by surname then first name. Choosing the column order is choosing which queries the index can answer, and the usual guidance is equality columns first, then the range or ordering column.

A covering index is the next step: if the index contains every column the query needs, the database answers from the index without touching the table at all. On a read-heavy endpoint that removes half the work, and it is why adding one included column sometimes beats adding a cache.

There are ways to disable an index by accident, and they are worth memorising because each looks harmless in review. Wrapping the column in a function, comparing it against a different type so the database has to cast, and starting a LIKE pattern with a wildcard all mean the index's ordering no longer matches the question being asked. Expression indexes exist for the first case, when the function is genuinely needed.

Finally, indexes are not only B-trees. Hash indexes serve equality alone, GIN and inverted indexes serve containment and full text, and BRIN indexes are tiny and effective for columns that correlate with physical order, such as an append-only timestamp. Recognising when the default is the wrong shape is worth more than tuning the default.

Check yourself

You have an index on (country, city). Which query can it not seek on efficiently?

prefix matchesprefix matchesnothing to seek ontargetedor a skip scan at bestif every column is presentcountry = UKleading columncountry and cityfull prefixcity = Londonno prefix(country, city)sorted by country,then citySeeka handful of pagesScanevery page in thetableCovering indexanswered without thetable
ClientData storeServiceExternalDashed = asynchronousConsidered, not chosen
A composite index serves a prefix of its columns, not any subset
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

03

Normalisation and when to break it

intermediate

Normalising means storing each fact exactly once, so an update touches one row and the data cannot contradict itself. The customer's address lives in the customer table and every order refers to it. That is the default, it is right most of the time, and it costs joins on read. Denormalising duplicates data to avoid those joins. Reads get faster and simpler, and every copy becomes a thing that can drift from the original. The trade is write complexity for read speed, and it is worth making when reads dominate and there is a reliable mechanism for keeping the copies current: a trigger, an outbox, a stream consumer, a scheduled rebuild.

There is a category of duplication that is not denormalisation at all, and confusing the two causes real bugs. The price on an order line is not a cached copy of the product price; it is the price at the time of sale, and it must not change when the product is repriced. Any value that is part of a historical record belongs on that record permanently. Copying it is correct modelling rather than an optimisation.

The usual mature shape is a normalised source of truth plus deliberately denormalised read models, updated asynchronously. That is the same idea as a materialised view, and where the database offers one, using it is preferable to hand-rolled duplication, because the refresh path is somebody else's tested code rather than yours.

The classical normal forms are worth knowing at a level of one sentence each. First: no repeating groups, one value per column. Second: every non-key column depends on the whole key. Third: and on nothing but the key. Almost every practical schema stops there, and the higher forms exist mostly in exam papers.

The one thing to avoid is denormalising early because joins feel expensive. A join on an indexed foreign key is one of the cheapest operations a relational database performs, and the intuition that it is slow usually comes from a missing index rather than from the join itself.

Check yourself

What does third normal form require, in one sentence?

the defaultwhen reads dominateneeds a refresh pathmust not follow the productNormalised tableseach fact stored onceJoin on readcheap on an indexedkeyRead modelrefreshedasynchronouslyCopies can driftthe real cost, notdiskPrice when solda different fact, nota copy
Data storeServiceExternalDashed = asynchronousConsidered, not chosen
One fact stored once, and the copies that are not copies
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

04

Relational or document

intermediate

Relational databases are the default for reasons that are easy to undervalue until they are gone: you can query the data in ways nobody anticipated, the database enforces constraints rather than trusting every writer, and a transaction can span several rows in several tables. Choose otherwise with a reason you can state in a sentence.

Document stores suit data that is read as a whole unit and whose shape varies per record: a product with wildly different attributes per category, an event payload, a document that genuinely is a document. The gain is that the record matches the object, and the loss is that a question the schema did not anticipate becomes a scan or a second copy of the data.

Key-value stores serve lookups by one known key at very high volume, and nothing else. Wide-column stores such as Cassandra sit near them and are designed around the query: you model the tables from the access patterns, accept duplication as normal, and get linear write scaling and no ad hoc querying at all.

The honest summary is that most applications fit a relational model, and a managed Postgres will carry far more load than most products ever see. It also handles JSON well, with indexing on document fields, which covers the semi-structured part of a schema without a second database and a second operational burden.

Schemaless is the claim that deserves the most scepticism, because there is always a schema: it either lives in the database, where it is enforced once, or in every piece of code that reads the data, where each has its own slightly different idea of what a record looks like. The second option is not the absence of a schema, it is a schema nobody can query and nobody maintains.

The strongest argument for a second store is a genuinely different access pattern rather than a performance claim: full text search, time series at high ingest rates, a graph traversal that would be a self-join twelve levels deep. Those are real, and they are much rarer than the number of polyglot architectures in the wild suggests.

Check yourself

What does a wide-column store such as Cassandra ask you to do differently?

the defaultshape and access fitvolume, one keyone place to enforcethe schemaless claimUnplanned queriesand multi-rowtransactionsRead whole, by idshape varies perrecordOne known keyat very high volumeRelationalconstraints enforcedonceDocumentrecord matches theobjectKey-valuelookups and nothingelseSchema in the DBchecked on every pathSchema per readernot absent, justunenforced
ClientData storeServiceDashed = asynchronousConsidered, not chosen
The access pattern chooses the store, and the schema exists either way
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

05

Connection pooling

advanced

A database connection is not a cheap handle. In Postgres each one is an entire operating system process with its own memory, so the cost is measured in megabytes rather than kilobytes, and the practical ceiling arrives well before most people expect, often in the low hundreds. Past it the failure is not gradual: the database refuses new connections outright while the ones it holds are still being served, so the symptom is a wall rather than a slope.

The instinct is to raise the limit, and it buys very little. Each additional slot is another process, so the ceiling moves a little and then the machine runs out of memory instead, and along the way every connection makes the shared work more expensive, because the internal bookkeeping the database does across its backends grows with their number. There is a real effect where throughput peaks at a concurrency close to the number of cores and then declines as more connections compete, which means a database with four hundred connections can complete less work per second than the same database with fifty.

A pooler sits in front and multiplexes many client connections onto a small number of real ones. Clients connect to it, it holds a modest set of server connections, and it hands one to whichever client currently has work. The application sees the connection count it wants and the database sees the one it can serve, which is the whole trick and the reason this is a configuration change rather than an architectural one.

Which pooling mode you choose decides how much that buys. Session pooling holds a real connection for the duration of a client's session and therefore helps almost not at all, since the ratio is unchanged for any client that stays connected. Transaction pooling returns the connection at every commit, so a hundred idle clients occupy no server connections at all, and that is where the large multiple comes from. Statement pooling goes further still and is rarely usable.

Transaction pooling has a price, and it is specific: anything whose lifetime exceeds a transaction stops working, because the next statement may arrive on a different server connection. Session variables, temporary tables, advisory locks, LISTEN and NOTIFY, and prepared statements are the usual casualties, and prepared statements are the one that bites hardest because ORMs and drivers often use them silently. Modern poolers can track and replay prepared statements per connection, which removes most of that, and it is worth confirming rather than assuming.

Serverless makes the whole problem sharper rather than different. Instance count follows traffic and each instance opens its own connections, so demand for slots peaks exactly when the database is busiest, and a function that scales to a thousand concurrent invocations will ask for a thousand connections from something that can serve two hundred. That is why every serverless platform either ships a pooler or expects you to put one in front, and why a data API over HTTP is the other common answer: it removes the connection from the equation entirely.

Check yourself

A serverless API starts failing with 'too many connections' under load. Best first fix?

thousandsone eachmultiplexedslots exhaustedreturned at commitServerless fleetcount follows trafficPoolertransaction modeDirect connectionsone process eachDozens of backendsthroughput peaks nearcore countNo slots leftrefused, not slowedWhat you give upsession state,advisory locks
ClientEdge / CDNData storeExternalDashed = asynchronousConsidered, not chosen
Many client connections, few real ones, returned at every commit
Evidence · 2 sources
  1. 01
    PgBouncer: pooling modes

    The difference between session, transaction and statement pooling, and which server-side features each one breaks.

  2. 02
    PostgreSQL wiki: number of database connections

    That each connection is a backend process, that raising max_connections buys little, and that throughput peaks at a concurrency near the core count rather than rising with connections.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.