Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

In PostgreSQL, ALTER TABLE ... ADD COLUMN ... NOT NULL fails on a table with existing rows because a new column without a default holds NULL for every old row, and those rows would violate the NOT NULL rule the moment it is added. The fix depends on what the old rows should contain. If every historical row should share one constant value, PostgreSQL 11 and later can add that column without rewriting each row. If each row needs its own value, add the column as nullable, backfill it in controlled batches, and tighten the rule in stages. This article uses PostgreSQL as its engine. Confirm the syntax and lock behavior against your own version, and see the SQL Server note near the end if you run a different engine.

Why the statement fails on a table with data

Suppose you run this on an orders table that already has rows:

ALTER TABLE orders ADD COLUMN fulfillment_state text NOT NULL;

PostgreSQL has to make the new column valid for every existing row at the moment it is added. Without a default, each old row receives NULL, which breaks NOT NULL, so the statement is rejected with an error of the form column "fulfillment_state" of relation "orders" contains null values. The table being empty would make the statement succeed, which is why it often works in development and fails in production.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A DEFAULT changes the outcome, but it changes it for a different reason than many people expect. A default tells PostgreSQL which value to supply when an insert omits the column. Adding a default in the same statement also gives existing rows a value, so the NOT NULL check passes. Whether that value is correct for those historical rows is a separate question, and that question decides which migration you should use.

The shortcut: a constant default on PostgreSQL 11 and later

The PostgreSQL 18 documentation states: “Adding a column with a constant default value does not require each row of the table to be updated when the ALTER TABLE statement is executed.” (PostgreSQL Global Development Group, PostgreSQL 18 documentation, “Modifying Tables,” https://www.postgresql.org/docs/current/ddl-alter.html.)

In practice, PostgreSQL 11 and later evaluate a non-volatile default once, store the result in table metadata, and return that value for rows that existed before the column was added. This makes the following statement fast even on a large table:

ALTER TABLE orders
  ADD COLUMN fulfillment_state text NOT NULL DEFAULT 'pending';

Fast does not mean lock-free. The statement still takes the ACCESS EXCLUSIVE lock that ALTER TABLE uses by default, so it can wait behind long-running transactions and hold up other queries while it waits.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When the shortcut is correct

  • Every existing row should genuinely carry the same value, for example a status that all historical records truly share.
  • The default expression is non-volatile. Test the exact expression rather than assuming that a literal-looking expression qualifies, because the rule depends on volatility, not on appearance.
  • Your PostgreSQL version is 11 or later. Check the version with SHOW server_version;.
  • Your deployment can tolerate the brief ACCESS EXCLUSIVE lock.

When it is not

  • Volatile defaults. A default such as clock_timestamp() is evaluated for each row. That requires per-row work and can force an update or rewrite of the table.
  • Blanket placeholders. A value such as 'unknown' or 'pending' may satisfy the constraint while misstating what happened to the old rows. Reports and downstream logic will then treat invented values as real history.
  • PostgreSQL versions before 11. Adding a default can require rewriting the table. Review the documentation for your exact version and test on a production-like copy first.

Choosing between the two paths

Decision point Constant default (PostgreSQL 11+) Row-specific staged migration
Historical meaning Every old row should receive the same correct value Each old row needs a value derived from its own data or a business rule
Work at DDL time Metadata change for eligible non-volatile defaults; no per-row update during ALTER TABLE Short nullable column add, then separate row updates, then a validation scan
Lock profile ACCESS EXCLUSIVE for the ALTER TABLE statement ACCESS EXCLUSIVE for each short DDL step; VALIDATE CONSTRAINT uses SHARE UPDATE EXCLUSIVE, which permits ordinary reads and writes to continue
Main risk A wrong blanket default, or a volatility or version assumption that does not hold Incomplete backfill, writers that do not yet set the value, batch pressure on the workload, validation failures
Typical fit A genuine domain default that all historical rows share Historical values that differ, or that must be computed

Lock descriptions reflect the PostgreSQL 17 and 19 ALTER TABLE references (https://www.postgresql.org/docs/17/sql-altertable.html and https://www.postgresql.org/docs/19/sql-altertable.html). Check the reference for your version before relying on them.

The staged migration for row-specific values

Use this path when the old rows need values that differ from one another. Before you start, set a lock timeout for the session running each DDL statement, so that a migration fails fast instead of queuing behind a long transaction and blocking every query that arrives after it:

SET lock_timeout = '3s';

If the statement times out, retry it later. Do not raise the timeout to make the migration wait.

Step 1: Add the column as nullable, without a default

ALTER TABLE orders ADD COLUMN fulfillment_state text;

Because no rows need values yet, this statement does not have to satisfy NOT NULL. It still takes a brief ACCESS EXCLUSIVE lock, so keep it short and run it inside your lock-timeout setting.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Step 2: Make every writer supply a valid value

Update every application version, background worker, import job, and administrative script that can insert or update the table so that it sets fulfillment_state to a valid value. During a rolling deployment, old code may still omit the column. In that window, you can add a temporary default that is a real domain default for new rows, but do not use a fake placeholder just to satisfy the constraint. Remove any temporary default in Step 7.

Step 3: Backfill old rows in bounded batches

Derive each value from the row’s existing columns or from an explicit business rule. The following example is illustrative only. It assumes that a shipped order has a shipped_at timestamp, and your own mapping will differ:

UPDATE orders
SET fulfillment_state = CASE
      WHEN shipped_at IS NOT NULL THEN 'shipped'
      ELSE 'awaiting_shipment'
    END
WHERE id > 1000000 AND id <= 1010000
  AND fulfillment_state IS NULL;

Run the job over stable key ranges, committing each batch separately. Because the predicate filters on fulfillment_state IS NULL, a batch can be rerun safely after an interruption, and rows that already received a value from the upgraded writers are skipped. Pause or slow the job when query latency, WAL generation, or replica lag rises. The batch size and pacing are operational choices for your workload, so measure them on a representative environment before you run the job in production.

Step 4: Prove there are no NULLs and that the values are correct

SELECT count(*) FROM orders WHERE fulfillment_state IS NULL;

SELECT count(*) FROM orders
WHERE shipped_at IS NOT NULL AND fulfillment_state <> 'shipped';

The first query should return 0. The second checks the derived values against the rule. Adapt it to your own mapping. A column that is populated but wrong passes the NOT NULL check and still causes damage, so verify meaning as well as presence.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Step 5: Add a NOT VALID check, then validate it

ALTER TABLE orders
  ADD CONSTRAINT orders_fulfillment_state_nn
  CHECK (fulfillment_state IS NOT NULL) NOT VALID;

ALTER TABLE orders
  VALIDATE CONSTRAINT orders_fulfillment_state_nn;

NOT VALID skips the initial scan of existing rows, but PostgreSQL enforces the check for every later insert and update. VALIDATE CONSTRAINT then scans the existing rows. It takes SHARE UPDATE EXCLUSIVE, which lets ordinary reads and writes continue, so this is the heavier-looking step that is safer to run under production load.

The check must be written so that it can actually prove the absence of NULLs. A CHECK constraint passes when its expression evaluates to TRUE or NULL. The expression fulfillment_state IS NOT NULL returns false for a NULL value and is therefore a valid proof. A weaker check such as fulfillment_state <> '' returns NULL for a NULL value, which passes, so it proves nothing about NULLs.

Step 6: Set the column to NOT NULL

ALTER TABLE orders
  ALTER COLUMN fulfillment_state SET NOT NULL;

PostgreSQL documents an optimization in which a valid CHECK constraint that proves there are no NULLs allows SET NOT NULL to skip the full table scan. Confirm that your version’s ALTER TABLE reference describes this behavior before you schedule the change, since the lock on this statement is still taken and the window still matters. Keep the CHECK constraint in place unless you have a separate reason to drop it. Dropping it is its own schema change.

Step 7: Remove temporary defaults after the rollout completes

Once every writer sets the value, drop any default that existed only for old code:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
ALTER TABLE orders
  ALTER COLUMN fulfillment_state DROP DEFAULT;

Keep a default only if it is a true domain default for new rows. The default for future inserts and the NOT NULL rule solve different problems, and one does not replace the other.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

What staging reduces, and what it does not change

The staged path avoids a single unbounded rewrite or scan during the initial column addition. It moves the row-by-row work into restartable batches, and it separates the schema change from the check of old data. That separation is the real benefit.

  • It does not give you zero locking. Each DDL step takes its own lock, and a waiting DDL statement can block the queries behind it.
  • It does not give you zero downtime or a predictable duration. The backfill competes for I/O, generates WAL, and can increase replication lag and application latency.
  • It does not remove the need for deployment coordination. Writers must be upgraded before the NOT NULL rule is enforced, or old code will start failing.
  • It does not make validation free. VALIDATE CONSTRAINT still reads the table.

Troubleshooting the common failures

  • SET NOT NULL reports that the column contains null values. The backfill is incomplete, or a writer that does not set the value is still active. Rerun the NULL count from Step 4 and find the writer that inserted the row.
  • The DDL statement waits, and queries pile up behind it. The lock timeout was reached, or a long transaction is holding a conflicting lock. Identify the oldest open transactions with SELECT pid, state, now() - xact_start AS age, left(query, 80) FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY age DESC;, then retry the DDL once the blocker finishes.
  • VALIDATE CONSTRAINT fails. Some existing rows still contain NULL. Find them with the query from Step 4, correct the data, and validate again. Do not drop the check to get past the failure.
  • Old application code fails after the NOT NULL rule is enforced. An insert path does not yet supply the value. Upgrade or fix that writer. The temporary default from Step 2 can cover it only while it remains in place.

The SQL Server contrast

Microsoft Learn states that a NOT NULL column can be added to a nonempty table if it has a DEFAULT, and that existing rows are populated with that default (https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-table-transact-sql?view=sql-server-ver17). The question of whether one value truly fits every historical row applies there as well. The PostgreSQL steps above do not translate directly. Their syntax, the NOT VALID and VALIDATE behavior, and the lock behavior are specific to PostgreSQL. Confirm the target engine, version, storage engine, and DDL algorithm before you adapt any of these commands.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.