Java Tutorial
🔍

Java Records

Java Records

A record is a class whose entire job is to hold a fixed set of values - and Java now lets you say exactly that, in one line, instead of writing the constructor, the accessors, and the equals, hashCode, and toString methods that every such class needs but none of them actually differ in logic. Declaring record Money(double amount, String currency) gives you all of it - immutable fields, a constructor, value-based equality, and a readable string representation - because the declaration itself says everything the compiler needs to know: this type IS these two values, nothing more.

What Is a Record?

A record is a special kind of class, introduced as a preview feature in Java 14, refined in Java 15, and finalized in Java 16. Its declaration lists the record's components - the values that define it - and the compiler generates private final fields for each component, a canonical constructor taking all of them in order, public accessor methods named exactly after each component (not getAmount(), just amount()), and equals(), hashCode(), and toString() implementations based on every component's value.

record Money(double amount, String currency) { }

  is roughly equivalent to the compiler generating:

final class Money extends java.lang.Record {
    private final double amount;
    private final String currency;

    Money(double amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public double amount()   { return amount;   }
    public String currency() { return currency; }

    // equals(), hashCode(), and toString() based on amount and currency
}

Basic Overview - What Declaring a Record Gives You

DECLARING A RECORD - record Money(double amount, String currency) { }
  Fresher view  : one line that gives you a constructor, accessor
                  methods (without "get"), equals, hashCode, and a
                  readable toString - all at once
  Deeper view   : the components (amount, currency) become BOTH the
                  record's private final state AND its public API -
                  the accessor names, and the canonical constructor's
                  parameter order, are the SAME list, everywhere

WHAT "IMMUTABLE" MEANS FOR A RECORD
  Fresher view  : once a Money object is created, its amount and
                  currency never change - to get a different value,
                  create a NEW Money
  Deeper view   : the fields are final by construction - there is no
                  syntax that makes a record component mutable.
                  Immutability here is enforced by the language, not
                  by convention or discipline

WHAT equals(), hashCode(), AND toString() MEAN FOR A RECORD
  Fresher view  : two separate Money objects with the same amount and
                  currency are equal to each other and print the same
  Deeper view   : equals() and hashCode() are generated PER COMPONENT,
                  recursively - a record containing another record
                  compares that nested record's components too, not
                  just whether it is the same object

WHEN A RECORD IS THE RIGHT CHOICE
  Fresher view  : "this type just holds some values together and has
                  little behavior of its own" - that is a record
  Deeper view   : a record models a VALUE - something fully defined
                  by what it contains, with no identity beyond that.
                  A type that needs identity tracked across changes
                  (a database row updated over time, for example) is
                  usually not a good fit for a record

A fresher can use the top-level summary as-is: declare the components, get a constructor and accessors for free, done. The "immutable by construction" and "recursive equals" points are where this topic moves from "shorthand for a class" to "the compiler is now enforcing something about this type that used to depend on the author getting every method right by hand."

Why Records Were Introduced

Before records, a class whose only job was to hold a fixed set of values still needed every piece of machinery a class with real behavior needs - written out, or generated by an IDE, every single time.

1// File: GeoCoordinate.java - BEFORE records, ~30 lines for two numbers 2 3import java.util.Objects; 4 5public final class GeoCoordinate { 6 7 private final double latitude; 8 private final double longitude; 9 10 public GeoCoordinate(double latitude, double longitude) { 11 this.latitude = latitude; 12 this.longitude = longitude; 13 } 14 15 public double latitude() { return latitude; } 16 public double longitude() { return longitude; } 17 18 @Override 19 public boolean equals(Object obj) { 20 if (this == obj) return true; 21 if (!(obj instanceof GeoCoordinate)) return false; 22 GeoCoordinate other = (GeoCoordinate) obj; 23 return Double.compare(latitude, other.latitude) == 0 24 && Double.compare(longitude, other.longitude) == 0; 25 } 26 27 @Override 28 public int hashCode() { 29 return Objects.hash(latitude, longitude); 30 } 31 32 @Override 33 public String toString() { 34 return "GeoCoordinate[latitude=" + latitude + ", longitude=" + longitude + "]"; 35 } 36}
1// File: GeoCoordinate.java - AFTER records, the whole thing 2 3public record GeoCoordinate(double latitude, double longitude) {}

Both versions behave identically - same accessor names, same equals() and hashCode() semantics based on the two values, same kind of toString() output. None of the thirty-odd lines in the "before" version contained any logic specific to GeoCoordinate - every method was completely determined by the fact that the class has exactly these two double fields. IDEs have generated this exact code for two decades, and annotation-based libraries exist specifically to generate it too. What records change is that the language itself now states the intent directly: declaring record GeoCoordinate(double latitude, double longitude) says "this type is these two numbers, and nothing else" - and the compiler, every tool, and every reader can rely on that being true without reading thirty lines to confirm it.

Syntax

record Money(double amount, String currency) { }

ALWAYS GENERATED:
  - private final double amount;
  - private final String currency;
  - Money(double amount, String currency) { this.amount = amount; this.currency = currency; }
      <- the "canonical constructor"
  - double amount()   { return amount;   }   <- accessor, NOT getAmount()
  - String currency() { return currency; }   <- accessor, NOT getCurrency()
  - equals(Object) - true if every component is equal (using each
    component's own equals())
  - hashCode() - combines every component's hashCode()
  - toString() - produces "Money[amount=1499.0, currency=INR]"

WHAT YOU CAN ADD INSIDE THE BODY:
  - a COMPACT constructor - validates or normalizes the canonical
    constructor's parameters (Common Use Case 1, below)
  - ADDITIONAL constructors - must delegate to the canonical
    constructor, directly or through another constructor
  - static fields and static factory methods
  - additional instance methods - derived values, business logic
  - "implements SomeInterface" - any number of interfaces

WHAT YOU CANNOT DO:
  - "extends AnotherClass" - records implicitly extend java.lang.Record,
    which uses Java's single class-inheritance slot
  - declare additional INSTANCE FIELDS beyond the record's components
  - declare a setter, or any method that reassigns a component
  - make a component anything other than implicitly private and final

Common Use Cases

Validated Value Objects With Compact Constructors

A compact constructor lets a record validate or normalize the values passed to its canonical constructor - without restating the parameter list, and without (this is the part that surprises people) explicitly assigning the fields. The compact constructor runs first; whatever values its parameters hold when it finishes are then assigned to the fields automatically, by the compiler, as if the canonical constructor's usual this.amount = amount; lines ran afterward.

1// File: MoneyDemo.java 2 3public class MoneyDemo { 4 5 record Money(double amount, String currency) implements Comparable<Money> { 6 7 // Compact constructor - validates and NORMALIZES the incoming 8 // parameters. Cannot write "this.amount = amount" here - the 9 // compiler performs that assignment automatically, using 10 // whatever 'amount' and 'currency' hold at the end of this block. 11 Money { 12 if (amount < 0) { 13 throw new IllegalArgumentException("Amount cannot be negative: " + amount); 14 } 15 currency = currency.toUpperCase(); // normalize, not validate 16 } 17 18 // Additional constructor - delegates to the canonical 19 // constructor as its first statement 20 Money(double amount) { 21 this(amount, "INR"); 22 } 23 24 // Static factory method 25 static Money zero(String currency) { 26 return new Money(0.0, currency); 27 } 28 29 // Derived instance method - returns a NEW Money; never 30 // mutates 'this', because records cannot be mutated 31 Money add(Money other) { 32 if (!this.currency.equals(other.currency)) { 33 throw new IllegalArgumentException("Cannot add different currencies"); 34 } 35 return new Money(this.amount + other.amount, this.currency); 36 } 37 38 @Override 39 public int compareTo(Money other) { 40 return Double.compare(this.amount, other.amount); 41 } 42 } 43 44 public static void main(String[] args) { 45 Money price = new Money(1499.0, "inr"); // lowercase - normalized by compact constructor 46 System.out.println("price : " + price); 47 48 Money defaultCurrency = new Money(500.0); // additional constructor 49 System.out.println("defaultCurrency: " + defaultCurrency); 50 51 Money total = price.add(defaultCurrency); 52 System.out.println("total : " + total); 53 54 System.out.println("zero INR : " + Money.zero("INR")); 55 56 System.out.println(); 57 System.out.println("=== Compact constructor validation ==="); 58 try { 59 new Money(-100.0, "INR"); 60 } catch (IllegalArgumentException e) { 61 System.out.println("Caught: " + e.getMessage()); 62 } 63 64 System.out.println(); 65 System.out.println("=== Comparable - sorting Money values ==="); 66 java.util.List<Money> amounts = new java.util.ArrayList<>(java.util.List.of( 67 new Money(500.0, "INR"), new Money(1499.0, "INR"), new Money(50.0, "INR"))); 68 java.util.Collections.sort(amounts); 69 amounts.forEach(m -> System.out.println(" " + m)); 70 } 71}
Output:
price          : Money[amount=1499.0, currency=INR]
defaultCurrency: Money[amount=500.0, currency=INR]
total          : Money[amount=1999.0, currency=INR]
zero INR       : Money[amount=0.0, currency=INR]

=== Compact constructor validation ===
Caught: Amount cannot be negative: -100.0

=== Comparable - sorting Money values ===
  Money[amount=50.0, currency=INR]
  Money[amount=500.0, currency=INR]
  Money[amount=1499.0, currency=INR]

Records as Map Keys

Because equals() and hashCode() are generated from component values, a record is usable as a HashMap key the moment it is declared - a brand-new instance with the same values as a previously stored key finds the same entry, with no extra code.

1// File: RecordAsMapKeyDemo.java 2 3import java.util.HashMap; 4import java.util.Map; 5 6public class RecordAsMapKeyDemo { 7 8 record ProductKey(String sku, String warehouseId) {} 9 10 public static void main(String[] args) { 11 Map<ProductKey, Integer> stockLevels = new HashMap<>(); 12 13 stockLevels.put(new ProductKey("SKU-1001", "WH-NORTH"), 120); 14 stockLevels.put(new ProductKey("SKU-1001", "WH-SOUTH"), 75); 15 stockLevels.put(new ProductKey("SKU-2002", "WH-NORTH"), 40); 16 17 System.out.println("=== Looking up with a NEW ProductKey instance ==="); 18 // A brand-new ProductKey, never stored anywhere before - works 19 // as a lookup key because equals()/hashCode() compare VALUES, 20 // not object identity 21 ProductKey lookupKey = new ProductKey("SKU-1001", "WH-NORTH"); 22 System.out.println("Stock for " + lookupKey + ": " + stockLevels.get(lookupKey)); 23 24 System.out.println(); 25 26 System.out.println("=== equals() and hashCode() are value-based ==="); 27 ProductKey a = new ProductKey("SKU-1001", "WH-NORTH"); 28 ProductKey b = new ProductKey("SKU-1001", "WH-NORTH"); 29 System.out.println("a == b : " + (a == b)); 30 System.out.println("a.equals(b) : " + a.equals(b)); 31 System.out.println("a.hashCode() == b.hashCode(): " + (a.hashCode() == b.hashCode())); 32 } 33}
Output:
=== Looking up with a NEW ProductKey instance ===
Stock for ProductKey[sku=SKU-1001, warehouseId=WH-NORTH]: 120

=== equals() and hashCode() are value-based ===
a == b      : false
a.equals(b) : true
a.hashCode() == b.hashCode(): true

Composing Records

Records can contain other records as components - and the generated equals() compares them recursively, by value, all the way down. toString() nests the same way, producing a readable representation of the whole structure.

1// File: NestedRecordsDemo.java 2 3public class NestedRecordsDemo { 4 5 record Address(String street, String city, String pincode) {} 6 7 record Customer(String name, Address address) {} 8 9 public static void main(String[] args) { 10 Address address = new Address("12 MG Road", "Bengaluru", "560001"); 11 Customer customer = new Customer("Ananya Sharma", address); 12 13 System.out.println("Customer : " + customer); 14 System.out.println("Customer's city : " + customer.address().city()); 15 16 System.out.println(); 17 18 System.out.println("=== equals() compares NESTED records by value too ==="); 19 Customer sameCustomer = new Customer("Ananya Sharma", 20 new Address("12 MG Road", "Bengaluru", "560001")); 21 System.out.println("customer.equals(sameCustomer): " + customer.equals(sameCustomer)); 22 } 23}
Output:
Customer        : Customer[name=Ananya Sharma, address=Address[street=12 MG Road, city=Bengaluru, pincode=560001]]
Customer's city : Bengaluru

=== equals() compares NESTED records by value too ===
customer.equals(sameCustomer): true

customer.equals(sameCustomer) is true even though address and the address inside sameCustomer are two different Address objects - Customer.equals() calls Address.equals() on them, and Address.equals() compares street, city, and pincode by value. Nothing about this nesting needed to be written by hand.

Real-World Example - Myntra Order Analytics

A reporting pipeline that turns a list of order line items into per-category revenue totals is exactly the shape records and streams were designed to compose around: one record represents each input row, a second record represents each output row, and the transformation in between reads almost like the specification of the report itself.

1// File: OrderItem.java 2 3public record OrderItem(String category, String productName, int quantity, double unitPrice) { 4 5 // Derived value - not stored, computed from the components 6 public double lineTotal() { 7 return quantity * unitPrice; 8 } 9}
1// File: CategoryRevenue.java 2 3public record CategoryRevenue(String category, double totalRevenue, int totalUnits) {}
1// File: OrderAnalyticsReport.java 2 3import java.util.Comparator; 4import java.util.List; 5import java.util.Map; 6import java.util.stream.Collectors; 7 8public class OrderAnalyticsReport { 9 10 public static List<CategoryRevenue> buildReport(List<OrderItem> items) { 11 Map<String, List<OrderItem>> byCategory = items.stream() 12 .collect(Collectors.groupingBy(OrderItem::category)); 13 14 return byCategory.entrySet().stream() 15 .map(entry -> new CategoryRevenue( 16 entry.getKey(), 17 entry.getValue().stream().mapToDouble(OrderItem::lineTotal).sum(), 18 entry.getValue().stream().mapToInt(OrderItem::quantity).sum() 19 )) 20 .sorted(Comparator.comparingDouble(CategoryRevenue::totalRevenue).reversed()) 21 .toList(); 22 } 23 24 public static void main(String[] args) { 25 List<OrderItem> items = List.of( 26 new OrderItem("Apparel", "T-Shirt", 3, 599.0), 27 new OrderItem("Footwear", "Sneakers", 1, 2499.0), 28 new OrderItem("Apparel", "Jeans", 2, 1299.0), 29 new OrderItem("Accessories", "Belt", 1, 499.0), 30 new OrderItem("Footwear", "Sandals", 2, 899.0), 31 new OrderItem("Apparel", "Jacket", 1, 2199.0) 32 ); 33 34 System.out.println("=== Individual order items (record toString, generated automatically) ==="); 35 items.forEach(item -> System.out.println(" " + item)); 36 37 System.out.println(); 38 39 System.out.println("=== Category revenue report, sorted by revenue ==="); 40 List<CategoryRevenue> report = buildReport(items); 41 report.forEach(category -> 42 System.out.printf(" %-12s Rs.%-9.2f %d units%n", 43 category.category(), category.totalRevenue(), category.totalUnits())); 44 } 45}
Output:
=== Individual order items (record toString, generated automatically) ===
  OrderItem[category=Apparel, productName=T-Shirt, quantity=3, unitPrice=599.0]
  OrderItem[category=Footwear, productName=Sneakers, quantity=1, unitPrice=2499.0]
  OrderItem[category=Apparel, productName=Jeans, quantity=2, unitPrice=1299.0]
  OrderItem[category=Accessories, productName=Belt, quantity=1, unitPrice=499.0]
  OrderItem[category=Footwear, productName=Sandals, quantity=2, unitPrice=899.0]
  OrderItem[category=Apparel, productName=Jacket, quantity=1, unitPrice=2199.0]

=== Category revenue report, sorted by revenue ===
  Apparel      Rs.6594.00   6 units
  Footwear     Rs.4297.00   3 units
  Accessories  Rs.499.00    1 units

Every method reference in buildReport - OrderItem::category, OrderItem::lineTotal, OrderItem::quantity, CategoryRevenue::totalRevenue - is calling a record accessor, generated from the component lists of OrderItem and CategoryRevenue. There is no separate "getter" naming convention to remember, and the toString() output printed for each OrderItem needed no formatting code at all - it came from the record declaration alone.

Combining With Other Features - Records, Sealed Interfaces, and Pattern Matching

Records compose particularly well with sealed interfaces and record patterns in switch (finalized in Java 21): a sealed interface lists every type allowed to implement it, records are a natural choice for those implementations since each one is just "a shape with these specific values," and a switch expression can destructure each record's components directly in its case labels.

1// File: RecordPatternDemo.java 2// Requires Java 21 or later for record patterns in switch 3 4public class RecordPatternDemo { 5 6 sealed interface Shape permits Circle, Rectangle {} 7 8 record Circle(double radius) implements Shape {} 9 record Rectangle(double width, double height) implements Shape {} 10 11 static double area(Shape shape) { 12 return switch (shape) { 13 case Circle(double radius) -> Math.PI * radius * radius; 14 case Rectangle(double width, double height) -> width * height; 15 }; 16 } 17 18 public static void main(String[] args) { 19 Shape circle = new Circle(5.0); 20 Shape rectangle = new Rectangle(4.0, 6.0); 21 22 System.out.printf("Circle area : %.2f%n", area(circle)); 23 System.out.printf("Rectangle area : %.2f%n", area(rectangle)); 24 } 25}
Output:
Circle area    : 78.54
Rectangle area : 24.00

The case Circle(double radius) -> line is doing two things at once: checking whether shape is a Circle, and - if so - binding its radius component to a local variable in the same step, with no cast and no call to .radius() needed. Because Shape is sealed and lists exactly Circle and Rectangle, the compiler can also verify that this switch covers every possible case, with no default branch required - a guarantee that plain interfaces cannot offer, because anything could implement them.

Best Practices

Reach for a record when a type is defined entirely by its values, with no identity beyond that. API request and response bodies, configuration snapshots, computation results, coordinates, money amounts - anything where "two instances with the same values ARE the same thing" is the correct mental model. An entity tracked across changes - a row in a database, an object whose identity matters even if its fields are later updated - is usually a poor fit, because a record's equals() will say two different "versions" of the same entity are unequal the moment any field differs.

Put validation and normalization in the compact constructor, not scattered across factory methods. Money's compact constructor rejects negative amounts and uppercases the currency code regardless of which constructor or factory method is used to create it - new Money(100, "inr"), new Money(100), and Money.zero("inr") all pass through the same compact constructor, so the invariant holds everywhere, by construction.

Prefer List, Set, and Map over arrays for record components. Records generate equals(), hashCode(), and toString() by calling those methods on each component - and arrays do not override any of the three from Object. A record with an array component will compare by reference and print an unreadable identifier for that component, while the same data as a List compares and prints exactly as expected.

Use records together with sealed interfaces when modeling a closed set of related shapes. The combination shown in this article - a sealed interface listing its permitted record implementations, consumed through a switch with record patterns - gives compile-time-checked exhaustiveness on top of records' value semantics, and is the modern replacement for many uses of the visitor pattern.

Common Mistakes

Mistake 1 - Array Components Break equals() and toString()

1// WRONG - 'tags' is an array. Arrays do not override equals() or 2// toString() from Object - they use reference identity. The record's 3// generated equals() and toString() call equals()/toString() on each 4// component AS-IS, so two Product records with "the same" tags 5// (different array objects, same contents) are NOT equal. 6record Product(String name, String[] tags) {} 7 8Product a = new Product("Shoes", new String[]{"footwear", "sale"}); 9Product b = new Product("Shoes", new String[]{"footwear", "sale"}); 10 11boolean equal = a.equals(b); // false - different array objects 12String text = a.toString(); // "Product[name=Shoes, tags=[Ljava.lang.String;@...]" 13 14// CORRECT - use a List for record components representing a 15// collection. List.equals() compares contents, and its toString() 16// prints the elements. 17record ProductFixed(String name, java.util.List<String> tags) {} 18 19ProductFixed c = new ProductFixed("Shoes", java.util.List.of("footwear", "sale")); 20ProductFixed d = new ProductFixed("Shoes", java.util.List.of("footwear", "sale")); 21 22boolean equalFixed = c.equals(d); // true - List.equals() compares contents 23String textFixed = c.toString(); // "ProductFixed[name=Shoes, tags=[footwear, sale]]"

Mistake 2 - Trying to Add a Setter

1// WRONG - record components are implicitly private and final. There 2// is no syntax for a "setter", and no way to reassign a component 3// after construction. 4record Money(double amount, String currency) { 5 void setAmount(double newAmount) { 6 this.amount = newAmount; // COMPILE ERROR - cannot assign to final field 7 } 8} 9 10// CORRECT - immutability means "change" produces a NEW record. A 11// method that looks like a setter instead returns a new instance. 12record MoneyFixed(double amount, String currency) { 13 MoneyFixed withAmount(double newAmount) { 14 return new MoneyFixed(newAmount, currency); 15 } 16}

Mistake 3 - Explicitly Assigning Fields Inside a Compact Constructor

1// WRONG - explicit assignment to a record component's field is NOT 2// ALLOWED inside a compact constructor, even though it looks like the 3// natural way to "finish" the constructor. 4record Money(double amount, String currency) { 5 Money { 6 if (amount < 0) { 7 throw new IllegalArgumentException("Amount cannot be negative"); 8 } 9 this.amount = amount; // COMPILE ERROR 10 this.currency = currency; // COMPILE ERROR 11 // The compiler performs these assignments AUTOMATICALLY, 12 // immediately after the compact constructor body finishes 13 } 14} 15 16// CORRECT - validate, and reassign the PARAMETER if normalization 17// is needed. The compiler assigns the (possibly updated) parameter 18// values to the fields afterward. 19record MoneyFixed(double amount, String currency) { 20 MoneyFixed { 21 if (amount < 0) { 22 throw new IllegalArgumentException("Amount cannot be negative"); 23 } 24 currency = currency.toUpperCase(); // reassigning the PARAMETER is fine 25 } 26}

Mistake 4 - Assuming a Record Can Extend a Class

1// WRONG - records implicitly extend java.lang.Record, using Java's 2// single class-inheritance slot. A record cannot extend anything else. 3class Person { 4 protected String name; 5} 6 7record Employee(String name, String department) extends Person { // COMPILE ERROR 8} 9 10// CORRECT - a record CAN implement any number of interfaces. Shared 11// behavior across several record types belongs on an interface, 12// using default methods, with each record implementing it. 13interface Identifiable { 14 String name(); 15 16 default String displayLabel() { 17 return "[" + name() + "]"; 18 } 19} 20 21record EmployeeFixed(String name, String department) implements Identifiable {}

Interview Questions

Q1. What is a record in Java, and what does the compiler generate automatically?

A record is a class, finalized in Java 16, whose declaration lists a set of components - the values that fully define it. For a declaration like record Money(double amount, String currency), the compiler generates a private final field for each component, a canonical constructor accepting all components in order, public accessor methods named exactly after each component (amount(), currency() - not getAmount()), and equals(), hashCode(), and toString() implementations based on every component's value. All of this happens with zero additional code beyond the one-line declaration.

Q2. What is a compact constructor, and how does it differ from the canonical constructor?

The canonical constructor is the full constructor matching the record's component list - taking amount and currency and assigning each to its matching field - generated automatically if not written explicitly. A compact constructor is written as Money followed directly by a body, with no parameter list (it implicitly takes the same parameters as the components, in the same order) and no explicit field assignments. Its body runs first, can validate the parameters (throwing if invalid) and reassign them for normalization, and then the compiler automatically assigns the resulting parameter values to the corresponding fields - equivalent to what the canonical constructor's body would otherwise do explicitly.

Q3. Why does a record with an array-typed component behave unexpectedly with equals() and toString()?

A record's generated equals(), hashCode(), and toString() work by calling those same methods on each component. Arrays in Java do not override equals(), hashCode(), or toString() from Object - they use reference identity and print an internal type-and-hash identifier. So a record with a String[] component will report two instances with "the same" array contents as unequal (different array objects), and will print something unreadable for that component in toString(). The fix is to use a List (or another type that implements value-based equals()/toString()) instead of an array for any record component representing a collection.

Q4. Can a record extend a class, be extended itself, or implement interfaces?

A record cannot extend any class - it implicitly extends java.lang.Record, which consumes Java's single class-inheritance slot. A record also cannot be extended by another class - every record is implicitly final, for the same reason every enum constant set is closed: the value-based equality and the "this type is exactly these components" guarantee would not hold for an unknown subclass that might add fields. A record CAN implement any number of interfaces, exactly like a regular class - interface implementation is independent of the inheritance slot used by extends java.lang.Record.

Q5. How does a record compare to a traditional POJO using Lombok's @Data or IDE-generated boilerplate?

Functionally, for a simple immutable data carrier, the generated members are similar - both approaches produce a constructor, accessors, equals(), hashCode(), and toString(). The differences are where the guarantee lives and how visible it is. Lombok's @Data (and similar annotations) generate code via annotation processing - the guarantee depends on the annotation being present and configured correctly, is an external tool's behavior, and @Data by default also generates setters, making the class mutable unless additional annotations restrict that. A record's guarantees come from the language itself, are visible in the declaration with no annotation processor involved, are enforced by the compiler for every record without configuration, and are immutable by construction - there is no setter-generating option to misconfigure.

Q6. How do record patterns in switch expressions (Java 21) work with records, and what do they require?

A record pattern in a case label - case Circle(double radius) -> ... - both tests whether the value being switched on is an instance of that record type, and, if so, destructures it, binding each component to a new local variable in one step, with no cast and no accessor calls needed. This requires Java 21 or later (record patterns in switch were finalized in JDK 21, after preview status in 21's predecessors for related pattern-matching features). Combined with a sealed interface listing every permitted implementing type, the compiler can additionally verify that a switch using record patterns covers every case, making a default branch unnecessary - a level of compile-time exhaustiveness checking that ordinary interfaces and classes cannot provide.

FAQs

Are record fields really immutable - can reflection or anything else change them?

The fields are private final, which the Java language and the standard reflection API both respect under normal circumstances - there is no public, supported way to reassign them after construction. Reflection technically allows bypassing final field protections in some configurations (the same is true for final fields on any class, not specific to records), but doing so is explicitly working against the type's contract, not a normal or supported usage. For all practical application code, a record's components do not change after construction.

Can a record have static fields and methods?

Yes. static fields and methods on a record behave exactly as they do on any class - they belong to the record type itself, not to any instance, and are not part of the generated component-based machinery (equals(), hashCode(), toString(), accessors). Money.zero("INR") in the example above is a static factory method, declared exactly as it would be in a regular class.

Can a record have additional constructors besides the canonical one?

Yes, as many as needed - but every additional constructor must delegate to the canonical constructor, either directly (this(amount, "INR")) or through another constructor that eventually does. This ensures that no matter which constructor is used to create an instance, the canonical constructor (and any compact constructor validation/normalization it includes) always runs, so the record's invariants hold regardless of entry point.

Is a record a class or an interface, and what does it extend?

A record is a class - specifically, an implicitly final class that implicitly extends java.lang.Record. It is not an interface, and java.lang.Record is itself an abstract class (not an interface) that provides the contract every record fulfills - including requiring equals(), hashCode(), and toString() to be defined in terms of the record's components.

Can records be generic?

Yes - declaring record Pair<A, B>(A first, B second) is valid, and behaves exactly as expected: Pair<String, Integer>, Pair<Money, Money>, and so on, with the generated equals(), hashCode(), and toString() working correctly for whatever types A and B are bound to at each use site.

Do records work with frameworks like Jackson and JPA out of the box?

Modern versions of common JSON libraries, including Jackson, support records directly - serializing a record's components as JSON fields and deserializing into the canonical constructor, often with no extra configuration for simple cases. JPA entity support for records is more limited: JPA entities traditionally require a no-argument constructor and mutable fields for the persistence provider to populate, which conflicts with a record's immutability and single canonical constructor - records are generally a better fit for JPA projection results (read-only query result shapes) than for the entity types themselves, though support continues to evolve across persistence providers.

Summary

A record states, in its declaration alone, that a type is exactly the set of values listed as its components - and the compiler then generates everything that statement implies: private final fields, a canonical constructor, accessors named after the components, and equals(), hashCode(), and toString() based on those component values, recursively for nested records.

Two details separate working knowledge from fluency here. A compact constructor can validate and normalize the canonical constructor's parameters, but cannot explicitly assign the record's fields - the compiler does that automatically, using whatever the parameters hold when the compact constructor finishes. And a component's type matters for the generated methods: arrays break equals() and toString() because arrays do not implement either meaningfully, while List, Set, Map, and other records compose correctly all the way down.

The question worth asking when a new type is needed: is this type fully defined by the values it holds, with no identity beyond that? If yes, a record says so in one line, and the compiler holds every caller to that statement from then on - exactly the guarantee thirty lines of hand-written or generated boilerplate used to provide, without ever quite guaranteeing it stayed correct as the class evolved.

What to Read Next