StackPractices
intermediate By Mathias Paulenko

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:

Mermaid flowchart LR diagram
  1. A request comes in for User(id=5)
  2. The Identity Map checks whether User:5 is already in memory
  3. Hit: the existing instance comes back and no SQL runs
  4. 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/DbContext lifetime 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 find will 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

VariantScopeUse Case
Transaction-scopedLives for one unit of workDefault ORM behavior
Session-scopedLives for a user sessionWeb apps with long sessions
Process-scopedLives for the app lifetimeRead-heavy reference data
DistributedShared across servicesMicroservices 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

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.