You find a column called hRecord. It looks like every other Voyager foreign key — an h-prefixed handle, an integer, sitting right where a join target should be. You join it to the table you're expecting. The query runs. The numbers are wrong.
Nothing errored. That's the trap.
What a polymorphic foreign key actually is
Most foreign keys point at exactly one table, always. UNIT.hProperty always points at PROPERTY.hMy. You can look at the column name and know the join target with certainty.
A polymorphic foreign key doesn't work that way. The same column can point at different tables depending on the row — and the row itself carries a second column that tells you which table applies that time. Miss that second column, and the ID you're joining on is ambiguous: it might be a valid primary key in three or four different tables at once, and you have no way to know which one the row actually means.
In Yardi Voyager, this pattern shows up constantly through the hRecord and hCode columns. Across the standard schema, hRecord appears on 207 tables and hCode on 376 — plus a related pattern, hParent, on another 101. In every case, the target table isn't fixed by the column name. It's resolved by a companion discriminator column — typically iObjectType or iType — sitting in the same row.
Why Voyager builds it this way
The alternative to a polymorphic reference is a dedicated foreign-key column per target table. Voyager needs generic, reusable subsystems — attachments, memos, notes, workflow steps — that can attach to almost anything: a property, a tenant, a vendor, a purchase order, a work order, a lease. Building a separate hProperty, hTenant, hVendor, hWorkOrder... column on every attachments-style table for every possible target would be unworkable — dozens of always-null columns on every row, and a new column added to the table every time a new module needs to attach a note.
A polymorphic pair solves it with two columns instead of dozens: hRecord holds the ID, and the discriminator says what kind of thing that ID refers to. One generic subsystem, any target.
There's a registry table that makes the discriminator meaningful: Role, whose iObjectType/sObjectType columns enumerate the object types the pattern uses. The values are effectively a lookup table for "what does this number in the discriminator column mean" — property, tenant, vendor, unit, GL account, purchase order, work order, employee, and dozens more, each with its own integer code. Some representative examples from the standard object-type registry: 1 decodes to Tenant, 3 to Property, 4 to Unit, 5 to Vendor, 7 to Account, 26 to Purchase Order, and 542 to Commercial Lease. The exact code list is long — this is a real registry, not a handful of special cases — but the mechanism is the same throughout: the discriminator value tells you which table hRecord is a key into for that row.
Where the naive join goes wrong
Say you're querying a generic attachments table and you want to pull in the property name for every attached record. The instinct is to join hRecord straight to PROPERTY.hMy:
select a.*, p.sAddr1
from Attachments a
join PROPERTY p on p.hMy = a.hRecord
Two ways this fails, and neither one throws an error:
Silent under-counting. Any attachment row whose hRecord genuinely points at a property will match correctly. But you never filtered on the discriminator — so attachments pointed at tenants, vendors, or work orders also sit in the table, and their hRecord values happen not to exist in PROPERTY.hMy (or, worse, coincidentally match an unrelated property row by ID collision). The join either drops them or, if the ID space overlaps, silently attaches the wrong parent to the wrong attachment.
Silent over-counting on a downstream union. If a report unions this pattern across several assumed target tables to "cover the bases," rows that match more than one target purely by numeric coincidence get counted twice. Nothing in the result set flags that as wrong. The row count is just too high, and it takes a manual reconciliation against a known total to notice.
Both failure modes look like a subtly wrong report, not a broken query — which is exactly why the pattern deserves attention before you write the join, not after a stakeholder asks why the numbers don't reconcile.
Joining hRecord/hCode correctly
The rule is simple to state and easy to forget under deadline: never join a polymorphic reference column to a fixed table without also filtering on its discriminator.
select a.*, p.sAddr1
from Attachments a
join PROPERTY p
on p.hMy = a.hRecord
and a.iObjectType = 3 -- Property, per the Role/ObjectType registry
If you need attachments across several target types in one result set, branch per type — a case-driven set of joins or a series of type-filtered subqueries, each one pinned to its own discriminator value — rather than one unfiltered join and a hope that IDs don't collide.
hParent deserves the same caution even though it's slightly different in practice: it's usually a self-referential or module-hierarchy parent link, and which hierarchy it belongs to depends on the owning module — verify against that module before assuming the join target.
The broader lesson
Polymorphic references aren't a Voyager quirk to work around once and forget — they're a structural feature of how the schema keeps generic subsystems generic. Once you know to look for the discriminator column sitting next to hRecord or hCode, reading an unfamiliar table gets faster, not slower: the pattern tells you exactly what question to ask before you write the join, instead of after the report ships wrong.
Stop guessing at Voyager's join logic
This is the kind of trap that costs hours the first few times you hit it and seconds once you know the pattern. PropETL's SQL Query Assistant carries this convention — and the rest of Voyager's naming grammar, core spine joins, and enum decodes — as part of a queryable knowledge base covering 9,793 standard tables, 137,000+ columns, and 12,900+ relationships, right inside Claude. Ask it for the join path between two tables and it steers around exactly this kind of ambiguity.
Schema querying ships in every PropETL tier. Start the free 7-day trial — no credit card — and ask it about the next table you're not sure how to join.
