You write a query that should return one row per tenant, or one row per property, and it comes back with two, three, sometimes dozens of copies of the same row. The data isn't wrong and neither, usually, is your filter logic. The join is walking through a column that looks like a legitimate foreign key but isn't one — it's audit metadata, and Voyager's schema is full of it.
Not every FK-shaped column is a business relationship
Yardi Voyager's naming convention makes almost every table look joinable to almost every other table. Columns like hUserCreatedBy, hUserModifiedBy, hCreatedBy, and hUpdatedBy appear on well over two thousand tables across the schema, and every single one of them points at the same target: PMUSER, Voyager's user-account table. They exist so Voyager can answer "who created this row" and "who last touched it" — audit questions, not business ones.
The trap is that they're structurally indistinguishable from a real relationship. Same H-prefix naming, same integer key pointing at another table's primary key. Nothing in the column name tells you it's off-limits for a business join — you have to already know the convention.
How this actually produces duplicate rows
Here's the mechanism. Say you're joining two tables that both happen to carry hUserCreatedBy, and — deliberately or by accident, chasing what looks like a shared key — you join them through that column instead of through their real relationship. Every row in table A created by a given user now matches every row in table B created by that same user, whether or not the two rows have anything to do with each other. One user creating dozens of leases and dozens of charges over time turns into a join that multiplies rows instead of relating them: the classic fan-out, except invisible in the query text because the join looks perfectly ordinary.
The reason this trap is so easy to fall into is that PMUSER is small, universal, and reachable from almost anywhere in the schema — which makes it a tempting-looking two-hop bridge between tables that don't otherwise share an obvious key. It's exactly the kind of shortcut a plausible-looking join-path search would surface, and exactly the kind of shortcut that's wrong.
A second, related pattern: line-item fan-out
Audit columns aren't the only way a Voyager join multiplies rows — a more mundane version of the same lesson shows up in detail/line-item tables. DETAIL, for instance, carries one row per distribution line, not one row per parent transaction. Join a transaction-level query straight through to DETAIL without aggregating or applying SELECT DISTINCT, and a single transaction with four distribution lines returns as four rows instead of one — again, not a query bug, just a grain mismatch the query didn't account for. The fix is the same instinct in both cases: know what one row of the table you're joining into actually represents before you join into it.
Separately, Voyager also ships dedicated audit-log tables — things that record every field-level change as its own row, one event per change. Those aren't meant to be joined into business queries at all; they're for change-history lookups, and joining a property or lease table directly into one of them is a guaranteed multiplication of rows, not an edge case.
The dedicated audit-log tables are worse, not better
It's tempting to assume the fix is simple — avoid the hUserCreatedBy-style columns and everything else is safe to join freely. It isn't. Voyager also carries dedicated audit-log tables (AuditTableEntry and its detail table, plus AUDITLOGIN) that exist purely to record change history at massive scale — these are audit floods on any mature install, reaching into the tens of millions of rows, and they're built to log an event per field change, not to represent current-state entities. Joining a business table directly into one isn't a subtle trap the way the hUserCreatedBy columns are; it's a guaranteed multiplication of rows and a guaranteed slow query, and the right move is to exclude them from ordinary business joins entirely rather than try to filter your way out afterward.
A related but distinct source of what looks like duplication is reversed transactions. TRANS rows carry a status flag, and a reversed or voided transaction and its reversal can both still be present in the table — pull TRANS for a property without filtering that status, and a single real charge can appear to show up twice: once as the original posting, once as its reversal. That's not a join problem at all, and no amount of avoiding audit columns fixes it — it needs an explicit filter on the status field before aggregation, the same discipline as excluding audit edges from a join path.
Why this doesn't show up in most reference material
It's not documented anywhere obvious, because from Voyager's own perspective there's nothing wrong with these columns — they're doing exactly the job they were built for. The trap only exists at the intersection of "the naming convention makes this column look like a business FK" and "someone writing ad-hoc SQL has no way to know it isn't one" without either institutional memory or a schema reference that flags the distinction explicitly.
That's the gap PropETL's SQL Assistant is built to close. Its join-path discovery walks the relationship graph the same way a person would — except it knows which edges are audit metadata and excludes them from business join paths by default, routing you around the exact trap that produces duplicate rows instead of into it. Ask it how to join two tables and get a path that already knows the difference between "this connects two records" and "this just says who touched them." It's included in every tier, including the free 7-day trial — worth testing against a join you've been getting duplicate rows on for a while.
