PostgreSQL Too Many Columns (SQLSTATE 54011)

When a CREATE TABLE statement, SELECT list, or subquery result exceeds PostgreSQL's maximum column count, you will see an error like:

ERROR:  tables can have at most 1664 columns
SQLSTATE: 54011

or, depending on where the limit is hit:

ERROR:  target lists can have at most 1664 entries
SQLSTATE: 54011

The SQLSTATE is 54011 and the condition name is too_many_columns. It belongs to PostgreSQL error class 54Program Limit Exceeded — which covers hard internal limits rather than user data errors.

What This Error Means

PostgreSQL enforces a hard upper bound on the number of columns a table or query result can have. The documented limit is 1,600 columns per table, but the internal tuple descriptor structure actually allows up to 1,664 entries (the gap is reserved for system columns and dropped-column slots). In practice you will hit the error before reaching 1,664 because dropped columns in a table's history consume slots even after ALTER TABLE ... DROP COLUMN.

Error class 54 (Program Limit Exceeded) groups errors that arise from PostgreSQL's own architectural ceilings rather than bad data or syntax. Other members of this class include 54001 (statement_too_complex) and 54023 (too_many_arguments). Unlike class 22 (data exceptions) these errors are not recoverable by changing a value — the schema or query itself must change.

After 54011 is raised the current statement is aborted and, if you are inside an explicit transaction, that transaction is placed into an error state that requires a ROLLBACK (or ROLLBACK TO SAVEPOINT) before any further work can proceed. No data is written and no DDL is committed.

Common Causes

  1. Wide table design with hundreds of EAV-style columns. A table built by repeatedly running ALTER TABLE ... ADD COLUMN to store dynamic attributes can accumulate hundreds or thousands of columns over time, eventually crossing the 1,664-slot ceiling including dropped-column tombstones.

  2. ORM-generated migrations that never drop old columns. Some migration frameworks leave dropped columns as tombstones. If the live column count looks safe but the actual pg_attribute slot count (including dropped columns) is near the limit, adding even one column triggers the error.

  3. SELECT * across heavily JOINed views or CTEs. A query that SELECT * from a chain of views, each projecting many columns, can produce a target list that exceeds 1,664 entries even if no single base table is wide.

  4. Programmatic table creation. Code that generates a CREATE TABLE statement dynamically (for example, pivoting a result set into columns) can easily produce a column list that exceeds the limit.

  5. Inherited or partitioned tables. In table inheritance hierarchies, the child table's effective column count includes all columns from every parent. A deep hierarchy of moderately wide tables can collectively exceed the ceiling.

How to Fix too_many_columns

  1. Redesign wide tables using a key-value or JSONB pattern.

    Instead of hundreds of sparse columns, store dynamic attributes in a jsonb column:

    -- Before: one column per attribute
    ALTER TABLE events ADD COLUMN attr_001 text;
    ALTER TABLE events ADD COLUMN attr_002 text;
    -- ... hundreds more
    
    -- After: single JSONB column
    ALTER TABLE events ADD COLUMN attributes jsonb DEFAULT '{}';
    -- Query a specific attribute
    SELECT attributes->>'attr_001' FROM events;
    -- Index a frequently queried attribute
    CREATE INDEX ON events ((attributes->>'attr_001'));
    
  2. Reclaim dropped-column slots by rebuilding the table.

    Dropped columns leave tombstone slots in pg_attribute. VACUUM does not reclaim these. A full table rebuild does:

    -- Check current live + dropped column count
    SELECT count(*) FROM pg_attribute
    WHERE attrelid = 'your_table'::regclass
      AND attnum > 0;  -- includes dropped columns
    
    -- Rebuild to reclaim slots (rewrites the table)
    CLUSTER your_table USING your_table_pkey;
    -- or
    ALTER TABLE your_table SET (autovacuum_enabled = false);
    CREATE TABLE your_table_new AS SELECT * FROM your_table;
    -- then rename and swap
    

    A cleaner approach is pg_dump + restore, which only serializes live columns.

  3. Replace SELECT * with explicit column lists in wide queries.

    -- Problematic: expands to >1664 columns across joins
    SELECT * FROM a JOIN b ON ... JOIN c ON ... JOIN d ON ...;
    
    -- Fixed: list only the columns you actually need
    SELECT a.id, a.name, b.status, c.created_at
    FROM a JOIN b ON ... JOIN c ON ... JOIN d ON ...;
    
  4. Normalize the schema. If many columns represent repeating groups, move them to a child table with a foreign key. This is the relational-model solution and improves storage efficiency as well:

    -- Instead of order_col_1 ... order_col_50 on a parent table
    CREATE TABLE order_items (
        order_id bigint REFERENCES orders(id),
        item_index int,
        value text,
        PRIMARY KEY (order_id, item_index)
    );
    
  5. Audit slot consumption before hitting the limit.

    SELECT relname,
           count(*) FILTER (WHERE NOT attisdropped) AS live_columns,
           count(*) FILTER (WHERE attisdropped)     AS dropped_columns,
           count(*)                                  AS total_slots
    FROM pg_class c
    JOIN pg_attribute a ON a.attrelid = c.oid
    WHERE c.relkind = 'r'
      AND a.attnum > 0
    GROUP BY relname
    ORDER BY total_slots DESC
    LIMIT 20;
    

Additional Information

  • The 1,600-column soft limit and 1,664-slot hard limit have been present since at least PostgreSQL 8.x and have not changed through PostgreSQL 16.
  • Related SQLSTATE codes in class 54: 54001 (statement_too_complex), 54023 (too_many_arguments — function has more than 100 parameters).
  • Most application-level drivers (libpq, psycopg2, JDBC, asyncpg) surface this error as a server-side ProgrammingError or PSQLException with SQLSTATE 54011. The condition is not retryable; the application must fix the schema or query.
  • Wide tables with hundreds of columns impose a real per-row overhead: PostgreSQL's tuple header requires one null-bitmap bit per column even for non-null values. A 1,600-column row with mostly null values still pays the fixed overhead on every heap page read.
  • pg_dump only serializes live columns, so a dump-and-restore cycle is a clean way to reset dropped-column slot counts when rebuilding a table offline.

Frequently Asked Questions

Why does PostgreSQL say "1664 columns" when the documented limit is 1600?

The 1,600-column limit is the safe operational ceiling documented in the PostgreSQL manual. Internally, the tuple descriptor can hold 1,664 entries, but the extra slots are reserved for system columns (like ctid and xmin) and for tombstones left by ALTER TABLE ... DROP COLUMN. You may reach the 1,664-slot error before accumulating 1,600 live columns if the table has a history of dropped columns.

Can I increase the column limit with a configuration parameter?

No. The limit is a compile-time constant (MaxTupleAttributeNumber) in PostgreSQL source code, not a GUC parameter. Changing it requires patching and recompiling PostgreSQL, which is not a supported configuration path.

My table only has 900 columns — why am I getting this error?

Dropped columns are not removed from pg_attribute; they leave tombstone entries that still consume attribute slots. If the table has been through many ADD COLUMN / DROP COLUMN cycles, the total slot count (live + dropped) can exceed 1,664 even though \d tablename only shows a few hundred live columns. Run the diagnostic query in the fix section to count both live and dropped slots.

Does this error affect foreign tables or views?

Yes. Foreign tables defined with CREATE FOREIGN TABLE obey the same column limit. Views do not store rows themselves, but a view's target list is checked when it is compiled into a query plan, so a view that expands to more than 1,664 column references will also raise 54011.

Subscribe to the Pulse Newsletter

Get early access to new Pulse features, insightful blogs & exclusive events , webinars, and workshops.

We use cookies to provide an optimized user experience and understand our traffic. To learn more, read our use of cookies; otherwise, please choose 'Accept Cookies' to continue using our website.