Mapping Objects to Tables
Phase 1 named the four jobs an ORM does. This phase is the first and most foundational one: mapping. Before an ORM can track your changes or build a query, it has to know one thing - which object goes with which row. Everything else builds on that answer.
Here's the mental model to hold the whole way through: mapping is a small set of correspondence rules. Not magic, and not a lot of rules - the ORM looks at your classes and your schema and lines them up, piece by piece, following the same handful of correspondences every time. Internalize those rules and you can predict what any ORM will do with any class, and the SQL it'll generate.
The correspondence rules
Five rules cover almost everything you'll meet:
- a class ↔ a table -
Userlines up with theuserstable. - a field / property ↔ a column -
user.emaillines up with theemailcolumn. - a primary key field ↔ the PK column - usually an
idfield maps to theidprimary key. - an object reference ↔ a foreign key -
order.customer(a pointer to another object) lines up withorders.customer_id(a foreign key column). - a collection ↔ a one-to-many -
customer.orders(a list of objects) lines up with many rows inordersthat share the samecustomer_id. And the special case: a many-to-many ↔ a join table.
The first three are "shape of one thing" rules. The last two are the interesting ones, because that's where the object-relational mismatch really bites: your code holds a reference (a direct pointer from one object to another), but the database has no pointers - only foreign keys, columns holding the value of some other row's primary key. The ORM's job is to fake the pointer using the key.
Seeing the rules on real classes
Say you have a Customer that owns a list of Order objects, and each Order points back to its
Customer. Here's the mapping the ORM applies, in pseudocode:
class Customer: table customers:
id ↔ id (PK)
name ↔ name
orders -> [Order] ↔ (no column - lives in orders)
class Order: table orders:
id ↔ id (PK)
total ↔ total
customer -> Customer ↔ customer_id (FK -> customers.id)
What just happened: The scalar fields (name, total) became plain columns. Order.customer became
the customer_id foreign key - the one column that actually stores the relationship. Customer.orders
has no column at all - it's the reverse view of that same foreign key. Ask for customer.orders and
the ORM runs SELECT * FROM orders WHERE customer_id = ?. One relationship, one foreign key, seen from
two directions.
Many-to-many is the case where neither side can hold the key - a student takes many courses, a course has
many students, and there's nowhere to put a single course_id on students. So the ORM introduces a
third table:
class Student: class Course: join table enrollments:
courses ──────────── students ↔ student_id (FK -> students.id)
course_id (FK -> courses.id)
What just happened: The enrollments join table holds one row per (student, course) pairing.
student.courses and course.students are both reverse views into that table - the ORM reads it from
whichever side you asked. You typically never write a class for enrollments; the ORM manages it behind
the collection. (If the relationship needs its own data - say, an enrollment date - most ORMs make you
promote it to a real class; see Relationships & Keys.)
💡 If a relationship ever confuses you, find the foreign key. The FK is the source of truth; the object reference and the collection are two convenient views of it. "Which table has the
_idcolumn?" answers "who owns this relationship?"
Convention, then configuration
How does the ORM know that Customer maps to customers and Order.customer maps to customer_id?
Two layers, true of every ORM:
- Convention - sensible defaults inferred from your names and types. Class
Customer→ tablecustomers(pluralized, lowercased). Fieldemail→ columnemail. A reference namedcustomer→ foreign keycustomer_id. You write nothing; the defaults carry you a long way. - Configuration - explicit overrides for when the defaults don't fit: a legacy table named
tbl_cust, a column calledemail_address, a key that isn'tid. You annotate the mapping to correct it.
The idea is identical across the ecosystem; only the syntax differs:
- Hibernate / JPA (Java) - annotations on the class:
@Entity,@Table(name=...),@Column,@ManyToOne,@JoinColumn. - SQLAlchemy (Python) - declarative models: a class subclasses a base, columns are class attributes, relationships use
relationship(). - GORM (Go) - struct tags:
gorm:"column:email_address"right on the struct field. - EF Core (C#) - conventions plus the Fluent API (
modelBuilder.Entity<...>().Property(...)) or data attributes.
💡 Reach for configuration only to correct a convention the ORM got wrong - not to restate one it already got right. Re-declaring
@Column(name = "email")on a field already named
The sharpest edge: inheritance
Here's where the mismatch cuts deepest. Your classes can inherit - SavingsAccount and
CheckingAccount both extend Account. SQL has no concept of inheritance: a table is a flat list of
columns, there's no "this table extends that one." So the ORM has to choose a strategy to flatten a
class hierarchy into tables, and each choice is a real tradeoff:
- Single-table - one table for the whole hierarchy, with a discriminator column (e.g.
account_type) saying which subclass each row is. Subclass-only columns are nullable for other rows. Fast queries (no joins), but a wide table full of nulls. - Joined / table-per-subclass - a base table (
accounts) plus one table per subclass (savings_accounts,checking_accounts), linked by sharing the primary key. Clean and normalized, but loading a subclass means a join on every read. - Table-per-class - each concrete class gets its own standalone table with all its columns
(inherited ones copied in). No joins for a single type, but "all accounts regardless of type" forces a
UNIONacross every table.
The tradeoff in one line: single-table buys query speed with nullable clutter; joined buys normalization with extra joins; table-per-class buys per-type simplicity with painful cross-type queries. Most teams reach for single-table unless the hierarchy is wide or the nulls become genuinely misleading. Know that the ORM is making this choice on your behalf, and that the strategy you pick shows up directly in the SQL you'll later debug.
Mapping runs both directions
📝 Mapping is bidirectional. On load, the ORM goes row → object: it reads a row and pours the column values into a fresh object's fields - this is called hydration. On save, it runs the reverse - object → row - reading your object's fields and writing them out as
INSERTorUPDATEcolumn values.
The same correspondence rules drive both directions - that's the point of having rules instead of
hand-written code. Hydration is also where foreign keys get turned back into references: when the ORM
hydrates an Order and sees customer_id = 42, it knows order.customer should resolve to the
Customer with id 42 (whether it fetches that customer now or later is the loading job, coming in a
later phase).
This load/save round-trip is why the relationship modeling in Relationships & Keys matters so much: the ORM can only hydrate references and collections correctly if the foreign keys it's reading are sound. Get the keys right in the schema, and the mapping rules do the rest, in both directions, every time.
Recap
- Mapping is a small, fixed set of correspondence rules - learn them once and you can predict any ORM's behavior and its SQL.
- The core five: class ↔ table, field ↔ column, PK field ↔ PK column, object reference ↔ foreign key, collection ↔ one-to-many (with many-to-many ↔ a join table).
- An object reference and a collection are two views of the same foreign key; the FK is the source of truth for who owns a relationship.
- ORMs map by convention (defaults from names/types) plus configuration (annotations / declarative models / struct tags / Fluent API) - configure only to override what the convention got wrong.
- Inheritance has no SQL equivalent, so the ORM picks a strategy - single-table, joined, or table-per-class - each trading query speed against normalization.
- Mapping is bidirectional: hydration turns rows into objects on load; the reverse turns objects into rows on save.
Quick check
[
{
"q": "Your code has `order.customer` (a reference to a Customer object). What does the ORM map that to in the database?",
"choices": ["A new column on the customers table", "A foreign key column like customer_id on the orders table", "A separate join table linking the two", "Nothing - references aren't stored"],
"answer": 1,
"explain": "An object reference maps to a foreign key. order.customer corresponds to orders.customer_id, a column holding the referenced customer's primary key."
},
{
"q": "Your table is named `tbl_cust` instead of the default `customers`. How do you tell the ORM?",
"choices": ["You can't - you must rename the table", "Through configuration (an annotation, tag, or fluent mapping) that overrides the convention", "By renaming your class to TblCust", "The ORM auto-detects any table name"],
"answer": 1,
"explain": "Convention gives defaults; configuration overrides them. You'd add an annotation/tag/fluent rule to point the class at tbl_cust."
},
{
"q": "What is 'hydration' in an ORM?",
"choices": ["Writing an object's fields out as an UPDATE", "Building a SQL query from an object", "Reading a database row and filling an object's fields with its column values", "Caching a query result for reuse"],
"answer": 2,
"explain": "Hydration is the row → object direction of mapping: on load, the ORM pours column values into a fresh object's fields."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Your code has `order.customer` (a reference to a Customer object). What does the ORM map that to in the database?
2. Your table is named `tbl_cust` instead of the default `customers`. How do you tell the ORM?
3. What is 'hydration' in an ORM?