Drizzle "table name specified more than once" in nested with
Get a deep findMany({ with }) tree running again: how the 63-byte alias limit makes two Drizzle aliases identical, a pre-flight check, and two tested fixes.
TL;DR#
A Drizzle relational query that goes five or six levels deep through with fails with table name "..." specified more than once, and the name in the message is cut off mid-word. Drizzle 0.x names each nested table by gluing the relation keys together with underscores, and PostgreSQL keeps only the first 63 bytes of an identifier. When two aliases in the same query are both longer than that and start with the same 63 bytes, PostgreSQL sees one name twice. On the stable 0.x line, split the tree into two queries so no alias path passes 63 bytes. The lasting fix is Relational Queries v2 on the Drizzle v1 release candidate, which names nested tables d0, d1, d2 instead.
The report is drizzle-orm issue #2066, filed on drizzle-orm 0.30.1 with PostgreSQL. The error it quotes:
error: table name "documentVersions_workflowStep_workflow_contract_entities_userEn" specified more than onceReproducing it on drizzle-orm 0.45.4#
The bug outlived the version it was filed on. It was reproduced on 19 October 2026 with drizzle-orm 0.45.4, the release the npm registry tagged latest that day, pg 8.23.1, Node 22.23.3 and PostgreSQL 16.15. The schema rebuilds the chain of relation names from the issue's alias up to userEntities (documentVersions has one workflowStep, which has one workflow, which has one contract, which has many entities, which have many userEntities), then adds a one relation user and a many relation notes under userEntities. The cardinalities are choices for the reproduction; the issue only shows the truncated alias. One row per table, plain Relational Queries v1 declared with relations().
Three depths of the same findMany, run one after the other, separate a long name from a broken query:
- Stop at
entities. The deepest alias is 56 bytes. The query returns its row. - Add
userEntities. One alias is now 69 bytes. PostgreSQL prints a notice and truncates it, but the query still returns its row, nesteduserEntitiesincluded. A single over-long alias is harmless, because every reference to it is truncated the same way. - Add
notesunderuserEntities. A second alias, 75 bytes long, truncates to the same 63 bytes as the first one. The query fails.
The third run, as the log recorded it. The run printed 15 notices, one per reference to the two aliases; the first five are shown:
NOTICE 42622: identifier "documentVersions_workflowStep_workflow_contract_entities_userEntities" will be truncated to "documentVersions_workflowStep_workflow_contract_entities_userEn"
NOTICE 42622: identifier "documentVersions_workflowStep_workflow_contract_entities_userEntities" will be truncated to "documentVersions_workflowStep_workflow_contract_entities_userEn"
NOTICE 42622: identifier "documentVersions_workflowStep_workflow_contract_entities_userEntities" will be truncated to "documentVersions_workflowStep_workflow_contract_entities_userEn"
NOTICE 42622: identifier "documentVersions_workflowStep_workflow_contract_entities_userEntities" will be truncated to "documentVersions_workflowStep_workflow_contract_entities_userEn"
NOTICE 42622: identifier "documentVersions_workflowStep_workflow_contract_entities_userEntities_notes" will be truncated to "documentVersions_workflowStep_workflow_contract_entities_userEn"
[... 10 more NOTICE 42622 lines for the same two identifiers, trimmed ...]
ERROR 42712: table name "documentVersions_workflowStep_workflow_contract_entities_userEn" specified more than onceThat is the exact message from the issue, byte for byte. Ending the tree on the one relation user instead of notes gave the second symptom that a commenter on the issue describes, a missing column:
[... 15 NOTICE 42622 lines for the userEntities and userEntities_user aliases, trimmed ...]
ERROR 42703: column documentVersions_workflowStep_workflow_contract_entities_userEn.user_id does not existIf you see either error on a deep with, look for the will be truncated to notices in your PostgreSQL log or driver output. They name the two original identifiers that collapsed into one.
Why it happens#
Two rules meet here, one in PostgreSQL and one in Drizzle.
PostgreSQL's limit is documented in Identifiers and Key Words: "The system uses no more than NAMEDATALEN-1 bytes of an identifier; longer names can be written in commands, but they will be truncated. By default, NAMEDATALEN is 64 so the maximum identifier length is 63 bytes." The cut is silent apart from a notice with SQLSTATE 42622 (name_too_long in the error code table); the error that follows is 42712, duplicate_alias, or 42703 for the missing column.
Drizzle's side is one line of the PostgreSQL dialect. In the installed drizzle-orm 0.45.4, node_modules/drizzle-orm/pg-core/dialect.js builds every nested alias like this (a grep -n for the assignment also returns three commented-out copies of it, at lines 624, 675 and 745):
[... 3 commented-out matches trimmed ...]
1020: const relationTableAlias = `${tableAlias}_${selectedRelationTsKey}`;The root alias is the table's key in your schema object, and each level appends _ plus the relation key. Printing the aliases of the failing query shows the growth, with the byte count first:
16 documentVersions
29 documentVersions_workflowStep
38 documentVersions_workflowStep_workflow
47 documentVersions_workflowStep_workflow_contract
56 documentVersions_workflowStep_workflow_contract_entities
69 documentVersions_workflowStep_workflow_contract_entities_userEntities
75 documentVersions_workflowStep_workflow_contract_entities_userEntities_notesEverything past byte 63 of the last two lines is dropped, and both become documentVersions_workflowStep_workflow_contract_entities_userEn. Notice that the table names in the database (document_versions, user_entities) play no part. Only the TypeScript keys do, which is why renaming tables in PostgreSQL does not help. By the same rule, depth alone is not the trigger: what counts is the total length of the keys on one path, so short keys allow a deeper tree and long keys hit the limit sooner.
Fix#
1. Find the aliases that will collide#
Before changing anything, list what Drizzle 0.x will generate for your with tree. This script walks the tree, applies the same concatenation rule, counts bytes, and clips on a character boundary at 63 bytes to spot collisions:
// alias-check.mjs: run with `node alias-check.mjs`
const LIMIT = 63; // NAMEDATALEN - 1 with the default build
export function aliasReport(rootKey, withTree) {
const out = [];
const walk = (prefix, tree) => {
for (const [key, value] of Object.entries(tree ?? {})) {
if (!value) continue;
const alias = `${prefix}_${key}`;
out.push({ alias, bytes: Buffer.byteLength(alias, 'utf8') });
if (typeof value === 'object') walk(alias, value.with);
}
};
walk(rootKey, withTree);
const seen = new Map();
for (const a of out) {
let cut = '';
for (const ch of a.alias) {
if (Buffer.byteLength(cut + ch, 'utf8') > LIMIT) break;
cut += ch;
}
a.collidesWith = seen.get(cut) ?? null;
if (!seen.has(cut)) seen.set(cut, a.alias);
}
return out;
}
const tree = {
workflowStep: { with: { workflow: { with: { contract: { with: {
entities: { with: { userEntities: { with: { notes: true } } } },
} } } } } },
};
for (const a of aliasReport('documentVersions', tree)) {
const flag = a.bytes > LIMIT ? (a.collidesWith ? `OVER, collides with ${a.collidesWith}` : 'OVER') : 'ok';
console.log(`${String(a.bytes).padStart(3)} ${flag.padEnd(4)} ${a.alias}`);
}Against the reproduction schema it flags exactly the pair PostgreSQL rejected:
29 ok documentVersions_workflowStep
38 ok documentVersions_workflowStep_workflow
47 ok documentVersions_workflowStep_workflow_contract
56 ok documentVersions_workflowStep_workflow_contract_entities
69 OVER documentVersions_workflowStep_workflow_contract_entities_userEntities
75 OVER, collides with documentVersions_workflowStep_workflow_contract_entities_userEntities documentVersions_workflowStep_workflow_contract_entities_userEntities_notesReplace tree and the root key with your own query. A line marked OVER alone produced only notices in the reproduction; a line marked collides with matches the pair that failed in both shapes run here, the one and the many ending.
2. On drizzle-orm 0.x: split the tree into two queries#
Cut the tree at the level where the aliases are still short, then start a second relational query from the table below the cut. The second query's aliases start from its own root key, so userEntities_notes is 18 bytes instead of 75. Stitch the two results together in JavaScript (this is the tested script without its notice logging and error handler):
import pg from 'pg';
import { inArray } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema.mjs';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool, { schema });
// Query 1: stops at entities, deepest alias 56 bytes
const docs = await db.query.documentVersions.findMany({
with: { workflowStep: { with: { workflow: { with: { contract: { with: { entities: true } } } } } } },
});
// Query 2: a new tree rooted at userEntities
const entityIds = docs.flatMap((d) => d.workflowStep?.workflow?.contract?.entities ?? []).map((e) => e.id);
const links = entityIds.length
? await db.query.userEntities.findMany({
where: inArray(schema.userEntities.entityId, entityIds),
with: { notes: true },
})
: [];
for (const d of docs) {
for (const e of d.workflowStep?.workflow?.contract?.entities ?? []) {
e.userEntities = links.filter((l) => l.entityId === e.id);
}
}
console.log(JSON.stringify(docs));
await pool.end();This costs one extra round trip, and the result has the same shape as the original single query. The run printed no truncation notice and returned the full tree:
OK [{"id":1,"workflowStepId":1,"workflowStep":{"id":1,"workflowId":1,"workflow":{"id":1,"contractId":1,"contract":{"id":1,"entities":[{"id":1,"contractId":1,"userEntities":[{"id":1,"entityId":1,"userId":1,"notes":[{"id":1,"userEntityId":1,"body":"signed"}]}]}]}}}}]The other way to stay on 0.x is to shorten the relation keys on the long path until the alias check shows no OVER line. That follows from line 1020, since only the keys feed the alias, though it was not run here. It also renames properties in every result object, and the issue's commenters note it makes the codebase harder to read. A community patch that hashes long aliases targets drizzle-orm 0.38.2 and was not tested here; the pull request that proposes a fix in the 0.x code, #3041, was still open and unmerged on 19 October 2026.
3. Move to Relational Queries v2 on the Drizzle v1 release candidate#
The maintainer closed the issue on 3 January 2026 with this comment: "This issue has been fixed in the beta tag, which serves as the release candidate for Drizzle v1". The comment names no version. On 19 October 2026 the npm rc tag pointed at 1.0.0-rc.4, which the upgrade guide installs with:
npm i drizzle-orm@rc
npm i -D drizzle-kit@rcA version bump is not enough. The fix lives in the new relational API, so you replace the per-table relations() calls with one defineRelations object and pass it to drizzle() as relations, as the v1 to v2 relations guide describes. For the reproduction schema, with the same relation keys:
import { defineRelations } from 'drizzle-orm';
import {
users, contracts, entities, userEntities, userEntityNotes,
workflows, workflowSteps, documentVersions,
} from './schema.mjs';
const tables = { users, contracts, entities, userEntities, userEntityNotes, workflows, workflowSteps, documentVersions };
export const relations = defineRelations(tables, (r) => ({
documentVersions: {
workflowStep: r.one.workflowSteps({ from: r.documentVersions.workflowStepId, to: r.workflowSteps.id }),
},
workflowSteps: {
workflow: r.one.workflows({ from: r.workflowSteps.workflowId, to: r.workflows.id }),
},
workflows: {
contract: r.one.contracts({ from: r.workflows.contractId, to: r.contracts.id }),
},
contracts: {
entities: r.many.entities({ from: r.contracts.id, to: r.entities.contractId }),
},
entities: {
userEntities: r.many.userEntities({ from: r.entities.id, to: r.userEntities.entityId }),
},
userEntities: {
user: r.one.users({ from: r.userEntities.userId, to: r.users.id }),
notes: r.many.userEntityNotes({ from: r.userEntities.id, to: r.userEntityNotes.userEntityId }),
},
}));
// elsewhere: const db = drizzle({ client: pool, relations });The object passed to defineRelations holds the tables only, not the old per-table relations() objects. The reproduction ran only Relational Queries v2 queries on rc.4; it did not test old-style queries alongside them.
Budget for the migration itself: Step 1 of the upgrade guide is drizzle-kit up, which rewrites the migrations folder (it removes journal.json and groups SQL files and snapshots into per-migration folders), and where and orderBy in relational queries become objects instead of callbacks. It is also a release candidate, not a stable release.
Verify the fix#
Run the deepest with tree your application uses and watch for two things: no will be truncated to notice, and the rows coming back. On 1.0.0-rc.4 the seven-level query ending on notes returned, with the longest quoted identifier in its SQL printed first:
longest quoted identifier in SQL: document_versions (17 chars)
OK [{"id":1,"workflowStepId":1,"workflowStep":{"id":1,"workflowId":1,"workflow":{"id":1,"contractId":1,"contract":{"id":1,"entities":[{"id":1,"contractId":1,"userEntities":[{"id":1,"entityId":1,"userId":1,"notes":[{"id":1,"userEntityId":1,"body":"signed"}]}]}]}}}}]Printing the table aliases of that query with toSQL() explains why depth no longer matters. Each nested table gets a depth index, and the matching line in the rc.4 dialect is aliasedTable(relation.targetTable, ...) with a d prefix plus the depth. The script printed the keys of the db object first; on rc.4 with node-postgres, for a db created with relations only, the list has no _query:
db keys: query,$with,$cache,dialect,session,tagged,_,$client
2 d0
2 d1
2 d2
2 d3
2 d4
2 d5
2 d6On the 0.x split, verify by running the alias check against each of the two trees: every line should read ok.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
When the deep query also carries a geometry column#
Relational Queries v1 return every nested level as JSON, and that path has its own failure mode. If the deep tree loads a PostGIS column and the next error is a RangeError from parseEWKB, continue with Drizzle's "Offset is outside the bounds of the DataView", which uses the same Relational Queries v2 escape route. If the split leads you to write a generic helper that passes subqueries to .from(), the TableLikeHasEmptySelection guide covers the TS2345 error such helpers hit on the stable line, and the call-site cast that avoids it. Truncation is one of the identifier rules from the same PostgreSQL docs section; the other one that trips ORM users is case folding of unquoted names, which is behind the Prisma 42P01 "table exists but raw query cannot find it" error.
The choice comes down to how many deep trees you have. One or two failing queries are cheapest to split; a codebase built on deep with trees is better served by Relational Queries v2, once the release candidate and the migration-folder rewrite are acceptable for your project.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix Drizzle Rollback: let the exception escape
Prove whether a swallowed manual rollback commits, then check the persisted rows after changing one catch handler.
How to Drop All Tables in PostgreSQL Safely (2026)
Dropping tables one by one is painful. Here is the one-command reset, the PostgreSQL 15 permission gotcha that breaks it, and the safe way to do it on Supabase.
PostgreSQL INSERT IF NOT EXISTS: ON CONFLICT and NOT EXISTS
Stop getting 'duplicate key value violates unique constraint' errors in PostgreSQL. Use INSERT ... ON CONFLICT DO NOTHING with RETURNING to atomically insert only when the row is missing.


