ORMs make relations feel like objects inside objects. SQL does not work that way.
In SQL, a table row only has columns. Relations are expressed with IDs:
trips.owner_id -> users.id
trip_days.trip_id -> trips.id
activities.trip_day_id -> trip_days.id
The mental shift is moving from object navigation to explicit data access.
ORM thinking
In Django or another ORM, related data often feels like it lives inside the object:
trip.owner
trip.days.all()
day.activities.all()
That is convenient because the application code can follow relationships in a natural way. It matches how people often think about domain objects: a trip has an owner, a trip has days, and a day has activities.
The risk is that the database work becomes invisible. What looks like simple attribute access may produce extra queries, large result sets, or an accidental object graph that is much bigger than the screen actually needs.
SQL thinking
SQL thinking is more explicit:
Which row in users has id = trips.owner_id?
Which rows in trip_days have trip_id = trips.id?
Which rows in activities have trip_day_id = trip_days.id?
The database gives you rows, not nested objects. Your application decides whether to keep those rows flat or transform them into nested JSON, DTOs, or domain objects.
This is the key distinction. The database stores normalized facts. Queries produce use-case-specific views of those facts.
Rows, not nested objects
An ORM can make it feel like the database contains this:
Trip
owner
days
activities
But the database usually contains separate tables:
users
trips
trip_days
activities
Foreign keys describe how those rows relate. They do not automatically create a nested structure. Nesting is an application decision.
That matters because different screens often need different shapes of the same underlying data. A trip detail page, a trip list page, an admin report, and an export job should not necessarily load the same object graph.
Three patterns for related data
Join related data
Joins are good for one-to-one or many-to-one data, where each main row has one related row or a small predictable set of related values.
SELECT trips.id, trips.name, users.email AS owner_email
FROM trips
JOIN users ON users.id = trips.owner_id
WHERE trips.id = 1;
This produces a flat result with exactly the columns needed by the use case.
Use multiple simpler queries
Multiple queries are often better for nested one-to-many data.
SELECT * FROM trips WHERE id = 1;
SELECT * FROM trip_days WHERE trip_id = 1;
SELECT * FROM activities WHERE trip_day_id IN (...);
Then the application assembles the result in code.
This can be clearer than writing one large query that duplicates parent rows across many child rows. It also gives the application control over how the final object or JSON structure is built.
Use PostgreSQL JSON aggregation
PostgreSQL can build nested JSON directly in the query:
SELECT
trips.id,
trips.name,
json_agg(trip_days.*) AS days
FROM trips
LEFT JOIN trip_days ON trip_days.trip_id = trips.id
GROUP BY trips.id;
This can be useful for APIs, but it is more advanced. It can also make queries harder to read and test if used too early. Use it when the database is the right place to shape the response, not just because nesting feels convenient.
Ask for the data shape
The practical question is not:
How do I load this object and all its relations?
The better question is:
What data shape does this screen or API endpoint need?
For example, a trip list page might need:
- trip id;
- trip name;
- owner name;
- day count;
- member count.
That needs a specific query, not a whole trip object graph.
The same trip might need a different query for a detail page, another query for editing, and another query for analytics.
Core SQL mindset
The core SQL mindset is:
Tables store normalized facts.
Foreign keys define relationships.
Queries produce use-case-specific views.
Application code shapes rows into objects or JSON.
This does not mean abandoning ORMs. It means not letting the ORM hide the shape and cost of the data access.
Practical rule
Use ORM navigation when it is simple and clear. For example, ordinary CRUD, small forms, admin screens, and straightforward model relationships are often fine with normal ORM code.
When a screen, endpoint, or job needs a specific shape of data, think in SQL terms first:
- which rows are needed;
- which columns are needed;
- which relationships are needed;
- whether joins, multiple queries, or aggregation produce the cleanest result;
- where the final shape should be assembled.
You are not replacing Django magic one-to-one. You are becoming explicit about what data you need, when you need it, and how it should be shaped.