Dev & EngARTICLE

PostgreSQL's 63-byte limit: the silent bug that drops your partition

The max_identifier_length parameter looks trivial, but the way PostgreSQL truncates long names can drop partitions, fire the wrong prepared statement, and create one role where you asked for two.

PostgreSQL's 63-byte limit: the silent bug that drops your partition
Image: Roberto Diniz

Every relational database imposes a ceiling on identifier length. What sets PostgreSQL apart, as Christophe Pettus points out in the "All Your GUCs in a Row" series published on Planet PostgreSQL, isn't the number itself (63), but what the server does when you exceed it. MySQL, SQL Server, and Oracle reject the command. PostgreSQL truncates the name to 63 bytes, emits a NOTICE, and moves on as if that were exactly what you meant. Almost every real-world problem involving this parameter stems from that single design decision.

For developers who generate object names dynamically (date-based partitioning, programmatic indexes, LISTEN/NOTIFY channels), this isn't a manual curiosity. It's a direct path to data loss with no error message at all.

What the parameter actually reports

max_identifier_length is a read-only preset, a sibling of block_size, integer_datetimes, and max_function_args. Its context is internal: SET, ALTER SYSTEM, and a line in postgresql.conf are all rejected with parameter "max_identifier_length" cannot be changed (in the config file's case, the server won't even start).

The reported value is NAMEDATALEN - 1, where NAMEDATALEN is a constant in src/include/pg_config_manual.h, fixed at 64 since PostgreSQL 7.3, in 2002 (before that it was 32). The minus one accounts for the C string terminator. The name type, used in relname, attname, rolname, and every identifier column in the system catalogs, is a fixed-width, 64-byte field with a trailing zero byte. That leaves you 63 bytes.

That fixed width is precisely why the limit doesn't get raised: columns that come after name in a catalog row sit at fixed offsets, and every row of pg_attribute and pg_class, plus every syscache entry derived from them, carries the full 64 bytes whether the name uses them or not. Doubling the constant would double that cost everywhere.

Where the cut happens, and where it doesn't

Truncation happens in the lexer, inside the truncate_identifier() function. That means it applies to anything that reaches the parser as an identifier: tables, columns, indexes, constraints, schemas, roles, databases, functions, prepared statement names, cursors, savepoints, and LISTEN channels. Quoting the name preserves case, but does nothing for length.

The detail that trips up anyone working with international text: the cut happens in bytes, not characters, though it lands on a character boundary. Forty copies of é become thirty-one (62 bytes, since 63 is odd); thirty CJK characters become twenty-one. As Pettus puts it, "bytes, not characters, is the mistake everyone makes once".

There's an important asymmetry: whatever arrives as a string literal is checked, not truncated. An enum label over 63 bytes throws an error; so does a long channel name passed to pg_notify(). But that same name delivered via NOTIFY (as an identifier) is silently clipped.

And the NOTICE? It carries SQLSTATE 42622, and being a NOTICE, it obeys client_min_messages. A human at the psql prompt sees the warning. An application almost never does, because application code doesn't read notices. It's that silence that turns a detail into an incident.

The failure mode is collision

Two names that match in their first 63 bytes are, as far as PostgreSQL is concerned, the same name. And generated names are where this hurts most, because naming conventions put the variable part at the end, exactly where the scissors cut.

Consider a daily partitioning scheme on a parent table with a 60-character name:

sql
CREATE TABLE customer_invoice_line_item_allocation_history_archive_detail_p2024_01_01
PARTITION OF customer_invoice_line_item_allocation_history_archive_detail
FOR VALUES FROM ('2024-01-01') TO ('2024-01-02');
-- NOTICE: identifier "...detail_p2024_01_01" will be truncated to "...detail_p2"
-- CREATE TABLE

CREATE TABLE customer_invoice_line_item_allocation_history_archive_detail_p2024_01_02
PARTITION OF customer_invoice_line_item_allocation_history_archive_detail
FOR VALUES FROM ('2024-01-02') TO ('2024-01-03');
-- ERROR: relation "...detail_p2" already exists

That's the good outcome, because it's an error. The bad one is the retention step in the same script: a DROP TABLE ..._p2023_12_31 that resolves to the same 63 bytes and drops the partition you created that morning, with no error and, if client_min_messages is set to warning, not even the notice. Pettus reports having done exactly this on version 18.6: the row count afterward was zero.

The same mechanism fires the wrong prepared statement, delivers a NOTIFY on ..._region_us to a session listening on ..._region_eu, and creates one role where you asked for two.

Why Postgres's internal names don't collide

It's worth understanding why the database itself doesn't suffer from this. The makeObjectName() function, which names implicit indexes, sequences, and constraints, shortens the table and column parts, never the label, and its callers retry with a counter on collision. Three unnamed indexes over the same columns of that long table produce ..._identifier_idx, ..._identifie_idx1, and ..._identifie_idx2, all 63 bytes long and all distinct.

Names that come from outside are your responsibility, and tooling support is uneven:

| Tool | How it handles the limit | |---|---| | pg_partman | Trims the parent name to fit the suffix (uses a cast to name, the correct approach) | | Django (PostgreSQL backend) | Has reported 63 since 2010 and hashes the tail of index names | | Rails | Started hashing in version 7.1; Action Cable measured characters instead of bytes until a fix landed on the 8.0 branch |

The practical approach: treat 63 as a budget

Since the parameter can't be changed (yes, you can recompile with NAMEDATALEN set to 128, but that requires initdb, breaks pg_upgrade against any standard build, and forces you to recompile every C extension), the advice falls back on the number itself.

The approach that makes sense is to treat 63 as a budget, charging the suffix first. If your convention appends _p2024_01_01 or _pkey, the base name gets what's left over, and a base name well above 45 characters is a collision waiting for a second table.

When a script generates names, check them before use. Two checks work on any build:

sql
-- the round-trip cast reveals whether truncation occurred
candidate::name::text <> candidate

-- the one task this parameter has always had
octet_length(candidate) > current_setting('max_identifier_length')::int

And to audit what already exists in the cluster, the following query lists everything that lands exactly at 63 bytes, meaning it was probably truncated:

sql
SELECT 'table' AS kind, relname::text AS name
FROM pg_class WHERE relkind IN ('r', 'p') AND octet_length(relname) = 63
UNION ALL
SELECT 'column', attname FROM pg_attribute WHERE octet_length(attname) = 63
UNION ALL
SELECT 'role', rolname FROM pg_roles WHERE octet_length(rolname) = 63;

Indexes and constraints are left out on purpose: names trimmed by PostgreSQL itself land at 63 by design. No one else lands at 63 by choice. Every row this query returns was longer when someone typed it, and the question in each case is what the rest of the name said.

Where this leaves the Brazilian developer

PostgreSQL is the most widely used database among people building software in Brazil, and the design pattern here tends to involve descriptive names in Portuguese, which burn through bytes quickly, plus frequent use of accented characters in labels and channels. The combination of long names with a byte-based (not character-based) cutoff is precisely the scenario Pettus describes as a trap.

The lesson here isn't about some exotic GUC. It's about modeling discipline: the right schema design, with a naming convention that reserves room for generated suffixes, eliminates this entire class of bug before it ever reaches production. Performance comes from the right design, and so does integrity.

Translated from the Brazilian Portuguese original · Read the original