You know the property data lives in PROPERTY. You know the transaction you need is in TRANS. In most schemas, that's most of the work — find the two tables, join on the obvious shared key, done. Voyager isn't most schemas. With 12,900+ relationships spread across an 9,793-table standard set, the join between two tables you can both name is frequently not a single obvious hop, and the column names along the way give you almost nothing to go on.
Worse, a wrong join in Voyager rarely throws an error. It just returns a number, and the number is wrong.
Why the column names don't help as much as they should
Start with something as basic as "which column on TRANS points back to the property." The honest answer is: it depends which era of the schema you're in. Roughly 387 tables reference PROPERTY through a column called hProp; a much larger set — around 1,169 tables — uses hProperty instead. Same target table, two different column names, because Voyager's modules were built over different periods without going back to standardize the older ones. Guess wrong and the join doesn't fail loudly — it just doesn't match, and a query that should return results comes back empty or partial with no obvious explanation.
That's a naming inconsistency. The harder problem is a column that means something different depending on the row.
The trap that actually breaks queries: polymorphic person references
TRANS.HPERSON is the clearest example of why "just join on the shared column" fails in this schema. It isn't consistently a tenant, a vendor, or anything fixed — what it points to depends on the transaction's type code. On charge and receipt rows it resolves to a tenant. On an AP invoice row it resolves to a vendor instead. On bank-level transactions it holds a bank record, not a party at all. And on journal entries, it's simply zero — there's no party to resolve.
Join TRANS.HPERSON straight to the tenant table without filtering by transaction type first, and you don't get an error — you get a result set that's silently wrong for every row that wasn't actually a tenant charge. Worse still: on an AP payment row, HPERSON isn't even the vendor being paid. Reaching the actual vendor on a payment means hopping through the detail line that links the payment to the invoice it clears, then reading the vendor off that invoice row — not off the payment itself. One column name, several genuinely different meanings depending on context nobody encoded into the column.
This is exactly the failure mode behind a warning worth internalizing before writing any revenue query against Voyager: the join goes through columns whose names give nothing away, and one wrong hop makes your revenue report double-count every reversal. Negative-amount rows in Voyager commonly represent reversals of an earlier posting — pull them in through a join path that wasn't built to account for that, and you're adding a transaction and its own reversal together as if they were two separate events instead of one net-zero pair.
Relationships that exist but shouldn't be walked
Voyager also has thousands of foreign-key-shaped relationships that are real columns, real constraints, and completely wrong to use as business joins. hUserCreatedBy and hUserModifiedBy appear on more than 2,000 tables and all point to the same user table — they answer "who touched this row," not "what business entity does this relate to." Full audit-trail tables built for change logging are worse: they're real relationships in the graph, but walking through them to reach business data multiplies rows in ways that have nothing to do with the actual question being asked. Any join-path tool that doesn't know to exclude these edges will hand you a technically-valid path that produces nonsense.
The path that usually is right — and why it's still not obvious
There is a well-worn chain underneath most of these queries once you strip the traps away: PROPERTY connects to UNIT through UNIT.hProperty, UNIT connects to TENANT through TENANT.hUnit (and TENANT also carries a direct property reference of its own), and the financial side runs from TRANS — the transaction header, where a type code discriminates charges from receipts from payables from journal entries — down through line-item detail into the ledger postings that actually hit the general ledger, filtered by which book (cash or accrual) the posting belongs to. That's a real, reusable spine, and it resolves the property in "PROPERTY to TRANS" correctly for the common case.
The catch is that nothing about the column names on any of these tables tells you this chain is the right one to use, as opposed to one of several other technically valid paths through the same 12,900+ relationships. You either already know the spine from experience, or you're rediscovering it from scratch under deadline pressure — which is a bad time to also be discovering the HPERSON polymorphism trap for the first time.
What actually resolves this without guessing
The reliable way to answer "how do I get from A to B" in a graph this size isn't memorization — it's a breadth-first search over the full relationship graph that finds the shortest real join chain between two tables, while deliberately excluding audit-trail edges from the search so it can't hand back a path that runs through change-logging noise. Give it PROPERTY and TRANS and it returns the actual chain — including the transaction-type filtering a correct query needs before trusting who HPERSON resolves to on any given row — instead of a plausible-looking guess.
That's the mechanism behind join-path discovery in PropETL's SQL Assistant: it walks the same 12,900+ inferred and confirmed relationships covering the full standard Voyager schema, steering around exactly the audit-trail edges described above. Ask it how two tables connect and it gives you the join chain and the traps to watch for on the way — not a starting point you still have to debug.
Once the join is right, bound the query the way any Voyager admin has to: filter GLDETAIL/GLTOTAL scans by property and posting-date range and ledger book before running anything against them, since they're the tables most likely to blow past ySQL's row cap. Getting the path right first is what makes that filtering meaningful instead of guesswork on top of guesswork.
Ask your own join question in Claude — schema search and join-path discovery are included in every PropETL tier. Start the free 7-day trial, no credit card required, and get the actual join chain instead of a wrong number that looks right.
