Child-to-parent queries with dot notation
The previous chapter showed how Salesforce
objects link to each other through lookup fields. This chapter puts that to work in the direction you
will use most: starting from a child record and reaching up to its parent. In SOQL there is no
JOIN keyword for this — you just write a dot, and Salesforce walks the relationship for you.
The dot is the join
Every Contact has an AccountId lookup pointing at its Account. To pull a parent field into
your result, write the relationship name, a dot, and the field:
SELECT LastName, Email, Account.Name FROM Contact WHERE Email != null
Each row comes back with the contact's own fields plus Account.Name resolved from the
related record. If you are coming from a relational database, this one line replaces an explicit join.
The equivalent SQL would be:
-- The SQL this replaces (for comparison only — this is not SOQL) SELECT c.LastName, c.Email, a.Name FROM Contact c LEFT JOIN Account a ON a.Id = c.AccountId
Note two things: you never name the join condition, because the lookup field already defines it, and you never alias tables, because the path itself says where each field lives. The full comparison of the two languages is in SOQL vs SQL; the short version is that SOQL trades join flexibility for join safety — you can only traverse relationships that actually exist.
Going further up
Dots chain. A Case looks up to a Contact, the Contact looks up to an Account, and the Account has an Owner (a User). You can walk the whole path in one field reference:
SELECT Subject, Status,
Contact.LastName,
Contact.Account.Name,
Contact.Account.Owner.Name
FROM Case
WHERE Status = 'New'
Each query can traverse up to five levels of parent in a single path, which covers almost anything you
will need in practice. Every hop is read from the perspective of the object before it:
Contact.Account.Owner.Name reads as "this case's contact's account's owner's name". When a
path stops making sense in plain English, that is usually a sign you are traversing the wrong lookup.
Custom lookups: __c becomes __r
Custom relationships work exactly the same way, with one spelling rule. The lookup field on a
custom object ends in __c, but when you traverse it, you swap the suffix for
__r (r for relationship). Suppose a custom Invoice__c object has a
Project__c lookup, and Project__c in turn has an Account__c
lookup to the standard Account object:
SELECT Name, Amount__c,
Project__r.Name,
Project__r.Account__r.Name
FROM Invoice__c
WHERE Status__c = 'Unpaid'
| You filter or select the raw Id with | You traverse with |
|---|---|
AccountId (standard lookup) | Account.Name |
Project__c (custom lookup) | Project__r.Name |
Account__c on a custom object | Account__r.Industry |
Mixing the two suffixes is one of the most common beginner errors: Project__c.Name is
invalid because Project__c is the Id field, not the relationship. If a traversal fails to
compile, check the suffix first.
__r suffixes and all. It is a fast way to learn the traversal paths
in your schema.
Null lookups keep the row — until you filter on one
Lookups are usually optional. A Contact with no Account still comes back from the first query in this
chapter — its Account.Name column is simply null. In SQL terms, selecting a parent field
behaves like a LEFT JOIN: missing parents never remove child rows.
Filtering is different, and this is the gotcha to remember. Ask for a parent field to equal a real value and rows with an empty lookup are silently excluded — a missing account cannot have any industry, let alone Technology, so contacts without an account never appear here:
SELECT LastName, Account.Name FROM Contact WHERE Account.Industry = 'Technology'
For those contacts the filter quietly turns your left join into an inner join. If you want orphaned
children included, say so explicitly:
WHERE Account.Industry = 'Technology' OR AccountId = null. The one check a missing
parent does satisfy is a null comparison: WHERE Account.Industry = null returns
both contacts whose account has no industry and contacts with no account at all, because
Salesforce treats the unreachable field as null. When a report "loses" rows after you add a parent
filter, this is almost always why.
Filter and sort by parent fields
Parent paths are valid anywhere a field is — SELECT, WHERE, and ORDER BY. The same comparison operators you already know apply to parent fields too:
SELECT FirstName, LastName, Account.Name, Account.AnnualRevenue FROM Contact WHERE Account.AnnualRevenue > 1000000 ORDER BY Account.Name, LastName LIMIT 50
This sorts contacts by their account's name first, then by last name within each account — grouping a flat result set by parent without any extra clauses. Just keep the previous section in mind: the revenue filter silently drops contacts whose lookup is empty. Sorting, by contrast, keeps them — a missing account simply sorts as null, which SOQL places first by default.
Polymorphic parents (an advanced aside)
Most lookups point at exactly one object. A few standard fields are polymorphic: the
WhoId on Task and Event can point at either a Contact or a Lead, and WhatId
can point at an Account, an Opportunity, and others. Traversing Who only gives you the
fields the possible parents share, such as Who.Name and Who.Type.
To reach type-specific fields, SOQL has a dedicated TYPEOF clause that branches on what the
parent actually is:
SELECT Subject,
TYPEOF Who
WHEN Contact THEN AccountId, Email
WHEN Lead THEN Company, Status
ELSE Name
END
FROM Task
WHERE ActivityDate = THIS_WEEK
Each row returns the branch matching its parent's type. This is genuinely advanced — most day-to-day queries never need it — so file it away and move on.
Where to next
You can now reach up from any child to its parents. The next chapter goes the other direction — one parent, many children — using subqueries, which behave quite differently from the dot notation you just learned.