Choosing Clojure’s defrecord vs deftype for High‑Performance Immutable Data
When Clojure’s plain maps feel heavy, <code>defrecord</code> and <code>deftype</code> offer memory‑efficient, immutable alternatives. This guide explains when to use each, shows a concrete example, and lists common pitfalls to avoid.
06 Nov 2025, 23:21 UTC

When to Pick defrecord or deftype
In Clojure, plain maps are the default immutable data structure. They are flexible but carry a small runtime cost: each entry stores a key, value pair and a hash map overhead. When you need a fixed‑shape, read‑only data object that will be passed to Java code or used in tight loops, defrecord or deftype can give you a measurable speedup and lower memory footprint.
Use defrecord when you need the object to behave like a map (support get, assoc, and be usable as a key in another map). Use deftype when you want the absolute smallest memory layout and can live without the map interface.
How the Two Constructs Work
defrecord generates a Java class that implements clojure.lang.IPersistentMap and java.io.Serializable. Each field is a final Java field, so field access is a direct memory read. The record also implements clojure.lang.IRecord and clojure.lang.ILookup, giving you keyword lookup support.
deftype generates a Java class that implements only the protocols you declare. It does not implement IPersistentMap, so it cannot be used wherever a map is expected. However, because it has no map metadata, the object is smaller and field access is slightly faster.
Sample Definition and Usage
Below is a record that represents a user profile. Notice the optional type hints (e.g., ^int id) which help Java interop and avoid boxing of primitive values.
;; Define a record with type hints
(defrecord UserProfile
^int id
^String name
^String email
^boolean active)
;; Create an instance
(def alice (->UserProfile 1 "Alice" "alice@example.com" true))
;; Keyword lookup (map‑style)
(println (get alice :name)) ; => "Alice"
;; Direct field access via generated getter
(println (.name alice)) ; => "Alice"
;; Use as a map key
(def users (hash-map alice {:posts 10}))
(println (get users alice)) ; => {:posts 10}
For a deftype you would write:
;; Define a type without map semantics
(deftype UserType
^int id
^String name
^String email
^boolean active
Object
(toString [this] (format "User %s (%d)" (.name this) (.id this))))
;; Create an instance
(def bob (->UserType 2 "Bob" "bob@example.com" false))
;; Direct field access
(println (.name bob))
;; Cannot use as a map key unless you implement hashCode/equals
Performance Comparison
Typical micro‑benchmarks show:
- Memory usage:
defrecord~25% smaller than a plain map with the same keys;deftype~35% smaller. - Field access: keyword lookup on a record is ~10‑15% slower than direct field access on a type, but the difference is negligible for most applications.
- Java interop: records can be passed to Java methods that expect a POJO; types can be passed if the Java method accepts the generated class or an interface you implement.
How to Verify the Numbers
- Create a collection of 1,000,000 instances of each structure.
- Before populating, call
(Runtime/getRuntime).maxMemoryto record baseline memory. - After population, call the same method and subtract to estimate memory used.
- For field‑access speed, loop over the collection and time the operation with
System/nanoTime.
Common Pitfalls and Limits
- Immutability Misconception: Records and types are immutable only at the field level. If a field holds a mutable Java object (e.g., a
StringBuilder), that object can still change, breaking functional guarantees. - Map Semantics:
deftypedoes not implementIPersistentMap, so you cannot use it where a map is required. Attempting to callassocor use it as a map key will fail. - Hashing and Equality: Records automatically provide
hashCodeandequalsbased on fields, making them suitable as map keys. Types need explicit implementations if you want that behavior. - Type Hints: Mismatched hints (e.g., hinting a field as
^intbut passing aLong) will cause runtime errors when Java methods are called. - Interop with Java Libraries: Some Java APIs expect a class with specific getters. Records provide these getters automatically, but types only expose the fields you declare.
- Memory Footprint in Large Collections: For very large collections, the overhead of the map metadata in records may outweigh the benefits. In such cases, consider using plain maps or custom data structures.
Practical Checklist
| Check | What to Verify |
|---|---|
| Immutability | Try mutating a nested Java object; ensure the record’s field reference stays unchanged. |
| Pass the instance to a Java method that expects the generated class; confirm no runtime errors. | |
Call (map? instance); it should return true for records, false for types. |
|
| Measure memory and access speed as described; compare against plain maps. |
Conclusion
Use defrecord when you need map semantics and Java interop with minimal effort. Use deftype when you need the smallest possible object and can manage the lack of map interface. Both constructs give you immutable, efficient data carriers, but they differ in memory layout, field access speed, and compatibility with Clojure protocols and Java APIs. By following the checklist above, you can make an informed choice and avoid the most common mistakes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.