← All posts

The Cost of a Nullable Column

A nullable column looks like flexibility — it's actually a decision you've postponed until every read site in your codebase has to make it for you

Adding NULL to a column takes about two seconds. Cleaning up what that decision propagates into your codebase takes considerably longer.

I've been thinking about this pattern lately because I keep seeing the same thing: a schema that was designed quickly, with nullable columns standing in for questions that hadn't been answered yet, and a codebase that's been slowly accumulating the tax ever since.

What NULL Actually Means

The trouble with NULL is that it means too many things.

It might mean the value is unknown. It might mean it doesn't apply. It might mean the record is still in progress and the value hasn't been set yet. It might mean a user skipped an optional field. Or it might mean something was deleted upstream.

The database doesn't know which of these you meant. It just knows the value isn't there. So every piece of code that reads the column has to figure out which of those meanings applies in context — and usually, it guesses based on whatever made sense to the developer writing that code at that moment.

That's how a nullable column becomes four different implicit behaviors across four different parts of an application.

The Query Tax

The most visible cost shows up in queries. Once a column is nullable, every filter, join, and aggregation involving that column becomes slightly more complicated.

WHERE status = 'active' no longer catches everything it should if some status values are NULL. You need WHERE status = 'active' OR status IS NULL — or you need to make a decision about what NULL means in this context, which is exactly the decision you didn't make when you added the column.

Indexes behave differently on nullable columns. Null-handling varies between database systems in ways that trip up people working across projects. And when you start joining across nullable foreign keys, the semantics of that join depend on a value that might not exist.

None of this is catastrophic in isolation. But every query involving a nullable column carries this footnote, and footnotes accumulate.

The Read Site Problem

The database layer is the visible part. The less visible part is what happens at every place in application code that reads the column.

Consider a shipped_at timestamp column that starts nullable. At the point of addition, it's nullable because orders might not have shipped yet — reasonable. But now:

  • The order detail view has to decide what to show when it's null. "Not shipped," probably. Fine.
  • The shipping report has to decide whether to include null rows in its counts. Included in total, excluded from shipped counts? That logic has to live somewhere.
  • The email notification system has to decide whether a null value means "don't send" or "send a different template."
  • The export job has to decide how to represent null in a CSV — empty string? The word "pending"? Skip the row?
  • The API response has to decide whether to return null, omit the field, or coerce it to something the client can handle without error.

Each of these is a decision. You didn't make most of them when you added the column — you deferred them, and now each read site is making them independently. Some of those decisions will be consistent. Some won't. You probably won't know which until a user reports something that looks like a bug but is actually two different places in the code disagreeing about what NULL means.

The Schema Is the Contract

I think about database schema as a contract between the application and its data. When a column is NOT NULL with a meaningful default or a well-specified domain, that contract is clear. The column has a value. Consumers can depend on it.

When a column is nullable, the contract is weaker. It says: this might be here, or it might not. You deal with it. The responsibility for that deferred decision gets distributed across every consumer.

In a small codebase with one developer, this is manageable. You know what you meant by the NULL. In a larger codebase, or one that's been through multiple developers over time, it's a slow leak. The original intent dissolves. The defensive null-checks proliferate. The edge cases multiply.

I've read code where every single access to a column was wrapped in a null check, not because NULL was a meaningful state that needed handling, but because the column was nullable and nobody wanted to be the one who didn't check. That's not defensive programming — that's a schema design problem metastasizing into application logic.

When Nullable Is Right

I want to be honest about the tradeoff here. There are genuinely nullable things.

A user's middle name. An optional note on an invoice. A deleted_at timestamp in a soft-delete pattern — null means the record is alive, non-null means it's been deleted. These are cases where "no value" is a legitimate, well-defined state, not a deferred decision.

The test I use: can I write a one-sentence definition of what NULL means for this column that any future developer would agree with? If yes, nullable might be right. If the answer is "it depends on context" or "we haven't decided yet" or "it's optional," those are warning signs. The first means the column probably shouldn't exist in this form. The second means the decision needs to happen before the schema does. The third deserves scrutiny — optional for whom, and what do we do when it's absent?

A lot of nullable columns get added because the alternative feels too constraining. "What if we need to handle cases where this doesn't exist?" The answer is usually: figure out what those cases are, give them explicit names, and model them explicitly. An enum with a not_applicable value is more honest than a nullable column. A separate table for optional data is more honest than a nullable foreign key. Explicit beats implicit, always.

The Discipline

The most useful habit I've developed is treating a nullable column the same way I treat a TODO comment — something that should exist temporarily, with a clear plan for resolution, not as a long-term design choice.

When I find myself about to add a nullable column, I ask: what am I not ready to decide yet? If I can name it, I can usually make the decision now or defer the column until I can. If I can't name it, I'm adding ambiguity to a contract that's supposed to be clear.

Schema decisions are expensive to reverse. A column's nullability is one of those decisions that gets baked into queries, indexes, application code, API contracts, and implicit expectations over time. Getting it right at schema time costs almost nothing. Fixing it later costs significantly more.

NULL isn't free. It's a deferred decision with interest.