Identity Map Pattern
Cache objects by primary key so each database row maps to a single in-memory instance per transaction. No duplicate objects, no lost updates.
Overview
Fetch the same row twice inside one transaction and something subtle goes wrong: you end up with two objects that both claim to be User(id=42). Edit one, save the other, and the second write silently erases your change. Most teams meet this bug as a flaky “my update disappeared” report long before they meet the pattern that prevents it.
The Identity Map keeps one lookup table per unit of work, keyed by primary key. Every load goes through it: if User:42 is already in memory you get that instance back, so find_by_id(42) called ten times returns the same object ten times. That’s the machinery behind ORM identity tracking — SQLAlchemy’s Session, Hibernate’s first-level cache, and EF Core’s DbContext all run on this idea.
When to Use
Use the Identity Map pattern when:
- The same row can be fetched more than once inside a single transaction
- Identity equality (
is/==) matters to the business logic - The Unit of Work or Data Mapper needs to know which objects are already loaded
- Two parts of a transaction might update the same row and clobber each other
If the entity you’re loading is really an aggregate spread across several tables or documents, look at the Composite Entity Pattern instead.
When to Avoid
- Read-only queries where duplicate instances don’t matter
- Stateless APIs where each request starts fresh and doesn’t reuse objects
- Cases where pinning every loaded object costs more memory than you can spare
- Long-running transactions where cached objects go stale while other writers move on
Solution
Python
import sqlite3
from typing import Dict, Optional, Type, Any
class User:
def __init__(self, user_id: int, name: str, email: str):
self.id = user_id
self.name = name
self.email = email
def __repr__(self):
return f"User(id={self.id}, name='{self.name}')"
class IdentityMap:
def __init__(self):
self._map: Dict[Type, Dict[Any, Any]] = {}
def add(self, entity):
entity_type = type(entity)
if entity_type not in self._map:
self._map[entity_type] = {}
key = self._extract_key(entity)
self._map[entity_type][key] = entity
def get(self, entity_type: Type, key: Any) -> Optional[Any]:
return self._map.get(entity_type, {}).get(key)
def has(self, entity_type: Type, key: Any) -> bool:
return key in self._map.get(entity_type, {})
def remove(self, entity_type: Type, key: Any):
type_map = self._map.get(entity_type)
if type_map:
type_map.pop(key, None)
def _extract_key(self, entity) -> Any:
return getattr(entity, 'id', None)
class UserMapper:
def __init__(self, connection, identity_map: IdentityMap):
self._conn = connection
self._identity_map = identity_map
def find_by_id(self, user_id: int) -> Optional[User]:
# Check identity map first
cached = self._identity_map.get(User, user_id)
if cached:
return cached
row = self._conn.execute(
"SELECT id, name, email FROM users WHERE id = ?", (user_id,)
).fetchone()
if row:
user = User(user_id=row["id"], name=row["name"], email=row["email"])
self._identity_map.add(user)
return user
return None
def find_all(self):
rows = self._conn.execute("SELECT id, name, email FROM users").fetchall()
users = []
for row in rows:
user = self._identity_map.get(User, row["id"])
if not user:
user = User(user_id=row["id"], name=row["name"], email=row["email"])
self._identity_map.add(user)
users.append(user)
return users
# Usage
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row # rows come back dict-like, so row["id"] works
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
conn.execute("INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')")
identity_map = IdentityMap()
mapper = UserMapper(conn, identity_map)
user1 = mapper.find_by_id(1)
user2 = mapper.find_by_id(1)
print(user1 is user2) # True — same object instance
Java
import java.sql.*;
import java.util.*;
public class User {
private final int id;
private String name;
private String email;
public User(int id, String name, String email) {
this.id = id; this.name = name; this.email = email;
}
public int getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
class IdentityMap {
private final Map<Class<?>, Map<Object, Object>> map = new HashMap<>();
@SuppressWarnings("unchecked")
public <T> T get(Class<T> type, Object key) {
return (T) map.getOrDefault(type, Collections.emptyMap()).get(key);
}
public <T> void add(Class<T> type, Object key, T entity) {
map.computeIfAbsent(type, k -> new HashMap<>()).put(key, entity);
}
public <T> boolean has(Class<T> type, Object key) {
return map.getOrDefault(type, Collections.emptyMap()).containsKey(key);
}
}
class UserMapper {
private final Connection conn;
private final IdentityMap identityMap;
public UserMapper(Connection conn, IdentityMap identityMap) {
this.conn = conn; this.identityMap = identityMap;
}
public User findById(int id) throws SQLException {
User cached = identityMap.get(User.class, id);
if (cached != null) return cached;
try (PreparedStatement stmt = conn.prepareStatement(
"SELECT id, name, email FROM users WHERE id = ?")) {
stmt.setInt(1, id);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
User user = new User(rs.getInt("id"), rs.getString("name"), rs.getString("email"));
identityMap.add(User.class, id, user);
return user;
}
}
}
return null;
}
}
// Usage (needs a JDBC driver on the classpath, e.g. sqlite-jdbc)
Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:");
conn.createStatement().execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)");
conn.createStatement().execute("INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')");
IdentityMap im = new IdentityMap();
UserMapper mapper = new UserMapper(conn, im);
User user1 = mapper.findById(1);
User user2 = mapper.findById(1);
System.out.println(user1 == user2); // true
JavaScript
class User {
constructor(id, name, email) {
this.id = id;
this.name = name;
this.email = email;
}
}
class IdentityMap {
constructor() {
this.map = new Map();
}
get(type, key) {
const typeMap = this.map.get(type);
return typeMap ? typeMap.get(key) : undefined;
}
add(type, key, entity) {
if (!this.map.has(type)) {
this.map.set(type, new Map());
}
this.map.get(type).set(key, entity);
}
has(type, key) {
const typeMap = this.map.get(type);
return typeMap ? typeMap.has(key) : false;
}
}
class UserMapper {
constructor(db, identityMap) {
this.db = db;
this.identityMap = identityMap;
}
async findById(id) {
const cached = this.identityMap.get(User, id);
if (cached) return cached;
const row = await this.db.get('SELECT id, name, email FROM users WHERE id = ?', id);
if (!row) return null;
const user = new User(row.id, row.name, row.email);
this.identityMap.add(User, id, user);
return user;
}
async findAll() {
const rows = await this.db.all('SELECT id, name, email FROM users');
const users = [];
for (const row of rows) {
let user = this.identityMap.get(User, row.id);
if (!user) {
user = new User(row.id, row.name, row.email);
this.identityMap.add(User, row.id, user);
}
users.push(user);
}
return users;
}
}
// Usage — a minimal async db stand-in matching node:sqlite / sqlite-style APIs
const db = {
rows: [{ id: 1, name: 'Alice', email: 'alice@example.com' }],
async get(_sql, id) {
return this.rows.find((r) => r.id === id) ?? null;
},
async all() {
return this.rows;
},
};
(async () => {
const im = new IdentityMap();
const mapper = new UserMapper(db, im);
const u1 = await mapper.findById(1);
const u2 = await mapper.findById(1);
console.log(u1 === u2); // true
})();
Companion code: the identity-map-pattern companion repo has all three implementations ready to run.
Explanation
Every load follows the same path:
- A request comes in for
User(id=5) - The Identity Map checks whether
User:5is already in memory - Hit: the existing instance comes back and no SQL runs
- Miss: the row is loaded, stored in the map, then returned
A few details decide whether the map helps or hurts:
- Key choice. Single-column primary keys are trivial; composite keys need a compound key — a tuple in Python, an immutable record in Java. Pick the wrong field and every lookup misses, which brings back the duplicate-instance bug the map exists to prevent.
- Lifetime. The map should die with the transaction or session that owns it. Keep it longer and it starts serving stale data: another process updates the row while your map still hands out the old instance. This is why ORMs bind the map to
Session/DbContextlifetime instead of the process. - Eviction and rollback. On rollback, drop the map — objects that were never committed don’t belong in it. On delete, remove the entry or
findwill resurrect deleted rows. - Concurrency. A map shared across threads needs synchronization. The usual fix isn’t a lock but scope: one map per unit of work means there’s nothing to share.
Variants
| Variant | Scope | Use Case |
|---|---|---|
| Transaction-scoped | Lives for one unit of work | Default ORM behavior |
| Session-scoped | Lives for a user session | Web apps with long sessions |
| Process-scoped | Lives for the app lifetime | Read-heavy reference data |
| Distributed | Shared across services | Microservices with shared caches |
The distributed variant stops being an Identity Map in the strict sense and becomes a shared cache — the Cache-Aside Pattern and the Two-Level Cache Pattern cover that territory.
What Works
- Scope the map to the transaction. Long-lived maps serve stale data.
- Pair it with Unit of Work. The map tracks what’s loaded; the unit of work tracks what’s dirty.
- Check the map in collection queries too.
find_all()should reuse instances, not mint duplicates of rows already loaded. - Clear it on rollback. Don’t leave half-committed objects behind.
- Use weak references on long-lived maps. If memory pressure matters, let unreachable entities get collected.
Common Mistakes
- The map outlives its transaction. Stale objects are the classic symptom: updates committed elsewhere never show up.
- Collection queries bypass the map. A
find_all()that skips the identity check defeats the point. - Deleted entities stay in the map. A removed object shouldn’t come back from
find. - Sharing one map across threads. Without synchronization or per-session scoping you get races that look like random corruption.
- Wrong key field. Mapping by a non-unique or mutable column produces collisions or permanent misses.
Real-World Examples
Hibernate’s first-level cache
Hibernate’s Session is an Identity Map: session.get(User.class, 5) returns the identical object on every call inside the same session. It’s also why merge() exists — it reconciles a detached instance with the one already sitting in the map.
Entity Framework Core
A DbContext tracks entities by key, so querying the same key twice hands back the same tracked object. AsNoTracking() is the deliberate opt-out for when you want fresh copies and no tracking overhead.
SQLAlchemy Session
SQLAlchemy documents the Session as an identity map directly. Loading the same row twice yields identical Python objects (user1 is user2 → True); expire_all() or a fresh session is how you ask for new state.
Further Reading
- Martin Fowler’s catalog entry: the original definition from Patterns of Enterprise Application Architecture
- SQLAlchemy: “Is the Session a cache?”: the ORM’s own take on why the session doubles as an identity map
- EF Core change tracking: how
DbContextdoes the same thing
Frequently Asked Questions
Is Identity Map the same thing as Hibernate's first-level cache?
Yes. Hibernate's session-level cache is a straight Identity Map implementation: one map per Session, keyed by entity type and primary key. When you hear "first-level cache" in JPA land, this pattern is what's underneath.
How is an Identity Map different from a normal cache?
Intent. A cache exists to skip expensive work and can evict entries whenever it likes. An Identity Map exists to preserve identity — evicting mid-transaction brings back duplicate instances, the exact bug it prevents. Speed is a side effect, not the point.
What key should I use for composite primary keys?
A compound key: a tuple like (order_id, line_no) in Python, or an immutable key record in Java. Whatever you pick must be unique per row and stable for the object's whole life — never a mutable field like an email address.
Does an Identity Map prevent stale reads?
No — it can cause them. The map answers from memory, so changes committed elsewhere won't show up until the map is cleared or the session ends. Keeping the map transaction-scoped is what makes that acceptable.
Can I use Identity Map without Unit of Work?
Yes. The two pair naturally — the map knows what's loaded, the unit of work knows what's dirty — but the map alone still buys you the core guarantee: same row, same object.
Related Resources
Data Mapper Pattern: Keep Domain Models DB-Free
Separate in-memory domain objects from the database by delegating persistence to a dedicated mapper layer, keeping models framework-agnostic.
PatternUnit of Work Pattern
Track changes to in-memory objects during a business transaction and commit all updates atomically to the database, ensuring consistency.
PatternData Access Object (DAO) Pattern
Abstract and encapsulate all access to a data source by exposing a clean interface while hiding persistence details from business logic.
PatternEager Loading Pattern: Fix the N+1 Problem
Load related data in a single query rather than multiple round-trips, preventing the N+1 problem and improving read performance.
RecipeCaching Strategies: Browser, CDN, Redis & Database
Implement effective caching at every layer of the stack: browser cache, CDN edge, Redis application cache, and database query cache — with invalidation strategies that keep data fresh.
RecipeCache Database Query Results with Redis and Python
Cache expensive database query results in Redis with cache-aside pattern, TTL management, and invalidation on writes for Python applications.