Adding Columns to Big Tables in PostgreSQL

The Problem

I needed to add a boolean column to a relatively large table. The standard advice is simple: avoid anything that locks the table. A naive ALTER TABLE with a default value on a big table does exactly that.

The column was a simple boolean flag. My first instinct was to make it nullable. A nullable column is just a metadata change — no rewrite, just a brief ACCESS EXCLUSIVE lock, done in milliseconds. But nullable means three possible states: true, false, and null. In Go, a plain bool will fail on a null scan. You have to use either a pointer (*bool) with nil checks, or a wrapper like sql.NullBool. Neither is clean.

But I also didn't want to use NOT NULL DEFAULT false and lock the table. So I ran an experiment. The results were not what I expected.

The Experiment

I tested both approaches on a table with 60 million records. Here's roughly what I ran:

-- nullable column (expected: fast, metadata-only)
ALTER TABLE users ADD COLUMN phone TEXT;

-- non-null column with a default (expected: slow, full table rewrite)
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;

Both finished in milliseconds, very close to each other.

I expected a full rewrite on 60 million rows to take a long time. Clearly something was off.

What PostgreSQL Actually Does

Since PostgreSQL 11, adding a non-null column with a stable default no longer requires a table rewrite. Both cases — nullable and NOT NULL with a default — are handled the same way: a fast metadata update.

The trick is in pg_attribute, a system table that stores information about every column. PostgreSQL 11 added two new fields to it:

When you run ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT true, PostgreSQL doesn't touch any existing rows. It just writes atthasmissing = true and attmissingval = {true} into pg_attribute. Done.

pg_attribute (simplified) attname attnotnull atthasmissing attmissingval id true false phone false false is_active true true {true} existing rows don't exist physically → use this value
is_active was added after existing rows — atthasmissing tells Postgres to fill from attmissingval

When PostgreSQL reads a row that was written before is_active existed, it checks pg_attribute. It sees atthasmissing = true, reads the default from attmissingval, and returns it. The old rows are never touched.

If a row is updated later, PostgreSQL writes the real value into it at that point. From then on, it no longer needs attmissingval for that row.

No existing rows are rewritten. Whether you add a nullable column or NOT NULL DEFAULT <stable value>, the cost is the same.

See It Yourself

Let's walk through this on a real table. Create a simple users table and fill it with 60 million rows:

CREATE TABLE users (
    id    BIGSERIAL PRIMARY KEY,
    name  TEXT NOT NULL
);

INSERT INTO users (name)
SELECT 'user_' || i
FROM generate_series(1, 60000000) i;

Now add both columns and time them:

\timing on

-- nullable column
ALTER TABLE users ADD COLUMN phone TEXT;
-- Time: 29.901 ms

-- non-null with a stable default
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
-- Time: 6.901 ms

Check what PostgreSQL stored:

SELECT attname, atthasmissing, attmissingval
FROM   pg_attribute
WHERE  attrelid = 'users'::regclass
  AND  attnum > 0
  AND  NOT attisdropped;
  attname  | atthasmissing | attmissingval
-----------+---------------+---------------
 id        | f             |
 name      | f             |
 phone     | f             |
 is_active | t             | {t}

is_active has atthasmissing = true and attmissingval = {t}. No rows were touched.

The Exception: Volatile Defaults

This only works when PostgreSQL can store a single fixed value. If the default is a volatile function — one that returns a different value every call — there's no single value to store. PostgreSQL falls back to a full table rewrite:

-- this forces a full rewrite on every existing row
ALTER TABLE users ADD COLUMN score FLOAT NOT NULL DEFAULT random();
-- Time: 39453.727 ms  (~39 seconds)

Common volatile functions: random(), clock_timestamp(), gen_random_uuid().

After the rewrite, check pg_attribute again. The atthasmissing flag on is_active is now cleared — PostgreSQL wrote the real values physically while it was already rewriting the table.

SELECT attname, atthasmissing, attmissingval
FROM   pg_attribute
WHERE  attrelid = 'users'::regclass
  AND  attnum > 0
  AND  NOT attisdropped;
  attname  | atthasmissing | attmissingval
-----------+---------------+---------------
 id        | f             |
 name      | f             |
 phone     | f             |
 is_active | f             |             ← cleared, now physically stored
 score     | f             |             ← volatile, was never set

If you need a UUID or a timestamp per row, don't use DEFAULT gen_random_uuid() or DEFAULT now() on a big table. Add the column as nullable first, backfill in batches, then set the constraint.

The Takeaway

Before PostgreSQL 11, the advice was simple: always add columns as nullable on big tables. NOT NULL DEFAULT x would trigger a full rewrite. That's no longer true for stable defaults.

The rules today:

PostgreSQL stores the missing value in pg_attribute and fills it in at read time for old rows. No rewrite, just a brief ACCESS EXCLUSIVE lock.

In the end I went with NOT NULL DEFAULT false. Clean type, no nil checks, just a brief ACCESS EXCLUSIVE lock. Sometimes the thing you were worried about isn't a problem anymore.