Date, Time & Money: java.time and BigDecimal Done Right

Two of the most common sources of production bugs in Java applications are date-time handling and money arithmetic. The old java.util.Date and Calendar classes were mutable (so one method could silently change a date it didn't own), had zero-based months (January is 0, which has confused every Java developer at least once), and mixed up "a moment in time" with "a wall-clock reading" in ways that made timezone bugs nearly inevitable. We mention them once so you can read legacy code — from here on, everything is java.time (Java 8+, current best practice in Java 25) and BigDecimal.

The java.time Toolbox: Pick the Right Type

java.time gives you several distinct types because dates and times are genuinely different concepts. Picking the right one is a decision rule, not a vocabulary test:

  • LocalDate, LocalTime, LocalDateTime — a calendar date and/or a wall-clock time with no timezone attached. Use them for things that are inherently local: a store's opening hours, a birthday, the date a report covers. A LocalDateTime is not a moment on the global timeline — it is the same moment everywhere until you pin it to a zone.
  • Instant — a single point on the global timeline (nanoseconds since the Unix epoch). This is what you store in databases and logs for event timestamps. No zone, no ambiguity: two systems on opposite sides of the planet agree on what an Instant means.
  • ZonedDateTime — a date-time plus a timezone (a set of rules, e.g. America/Denver). This is what you display to users.

The recipe: store UTC, display local

The standard pattern that prevents nearly every timezone bug:

  1. Store event timestamps as Instant (or UTC datetime columns — same thing).
  2. Display them by converting to the user's ZoneId with atZone().
  3. Never store a wall-clock LocalDateTime and assume you'll remember which zone it was in. You won't.
Instant orderPlaced = Instant.now();                       // store this
ZonedDateTime denver = orderPlaced.atZone(ZoneId.of("America/Denver"));
System.out.println(denver);                                // 2026-10-03T13:45:12.123456-06:00[America/Denver]

Zone vs offset: the distinction that bites

A zone is a set of rules ("Mountain Time flips between MST and MDT"). An offset is just a number ("UTC−6"). Two places can share the same offset today and follow different rules tomorrow — which is why hardcoding an offset like UTC-6 breaks when daylight saving starts or ends. Always use a region-based ZoneId (America/Denver, Asia/Kolkata), not a fixed offset.

Mar DST switch (spring forward) Apr Zone: America/Denver offset follows the rules: −07:00 → −06:00 Fixed offset: UTC−07:00 stays −07:00 — now one hour off

Duration vs Period: time-based vs date-based

Use Duration for exact amounts of time (seconds, hours, days on the timeline): session timeouts, retry delays, SLA windows. Use Period for calendar-based amounts (years, months, days): subscription terms, "30 days from today". The difference matters because a day isn't always 24 hours — across a DST transition it can be 23 or 25. Duration counts exact seconds; Period counts calendar days.

Duration sessionTimeout = Duration.ofMinutes(30);
Period subscriptionTerm = Period.ofMonths(12);
LocalDate expiry = LocalDate.now().plus(subscriptionTerm); // 2027-10-03

Parsing and formatting: DateTimeFormatter

DateTimeFormatter replaced the old SimpleDateFormat, which was famously not thread-safe — sharing one instance across threads could corrupt dates silently. DateTimeFormatter is immutable and thread-safe, so you can keep one as a static final constant.

DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd MMM uuuu");
LocalDate d = LocalDate.parse("03 Oct 2026", fmt);
System.out.println(d.format(fmt));   // 03 Oct 2026

Rule of thumb: use ISO format (LocalDate.parse("2026-10-03")) for anything machines read, and custom patterns only for human-facing display.

What actually breaks: the DST gap

Timezones bite hardest where the clock skips. In America/Denver, clocks spring forward on the second Sunday of March: 2:00 AM jumps straight to 3:00 AM. So 2:30 AM on that day does not exist. If you schedule "every day at 2:30 AM" with a naive local time, java.time resolves the gap by shifting forward to 3:30 AM (documented behavior), but a hand-rolled Calendar-based scheduler would silently fire an hour late or twice. The decision rule: when you need "the same civil time every day regardless of DST" (a daily report, a store opening), model it as a LocalTime plus a ZoneId, and let ZonedDateTime.of(localDate, localTime, zone) handle the gaps and overlaps. When you need "exactly 24 hours from now" (a session expiry, a retry delay), model it as an Instant plus a Duration — no zone involved at all.

Working with legacy code: the one-way bridge

You will still meet java.util.Date in old libraries and APIs. Don't mix the models — convert at the boundary and forget the old type immediately:

Date legacy = someOldApi.getTimestamp();
Instant modern = legacy.toInstant();          // java.util.Date -> Instant
Date back = Date.from(modern);                // Instant -> java.util.Date (only if an old API demands it)

Calendar converts via calendar.toInstant(). The pattern is always the same: legacy type comes in, Instant or ZonedDateTime takes over, and the legacy object never travels deeper into your code.

Money: Never double

This one is short because the rule is absolute for the money domain: never use double or float for money. Floating point is binary, and decimal fractions like 0.1 cannot be represented exactly in binary. Watch:

System.out.println(0.1 + 0.2);   // 0.30000000000000004

That trailing error is fine for physics simulations; it is a lost penny on every transaction when it compounds through tax, discount, and rounding calculations. Use BigDecimal.

The double-constructor trap

Constructing a BigDecimal from a double inherits the binary error — the damage is already in the argument:

BigDecimal wrong = new BigDecimal(19.99);
System.out.println(wrong);   // 19.989999999999998437...
BigDecimal right = new BigDecimal("19.99");
System.out.println(right);    // 19.99

Always build BigDecimal from a String. If you already have a double, BigDecimal.valueOf(19.99) is the safer route (it converts via the string representation).

Arithmetic: scale and rounding

BigDecimal is immutable — add and multiply return new values. Division needs an explicit rounding mode or it throws ArithmeticException on non-terminating decimals (like 10 ÷ 3). The industry-standard rounding for money is RoundingMode.HALF_UP ("round half up", the way prices are rounded at a register):

BigDecimal price = new BigDecimal("19.99");
BigDecimal qty = new BigDecimal("3");
BigDecimal subtotal = price.multiply(qty);                       // 59.97
BigDecimal tax = subtotal.multiply(new BigDecimal("0.0825"))
                        .setScale(2, RoundingMode.HALF_UP);       // 4.95
BigDecimal total = subtotal.add(tax);                            // 64.92
System.out.println(total);                                       // 64.92

Decision rule: setScale(2, HALF_UP) at each money boundary (line totals, tax, grand total) — not after every intermediate multiplication, or you'll accumulate rounding differences that won't reconcile with the finance team's spreadsheet.

compareTo vs equals: the HashSet surprise

BigDecimal.equals compares both value and scale, while compareTo compares only numeric value:

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.compareTo(b) == 0);  // true  — numerically equal
System.out.println(a.equals(b));          // false — different scale!
Set<BigDecimal> set = new HashSet<>();
set.add(a); set.add(b);
System.out.println(set.size());           // 2 — the HashSet surprise

Consequence: use compareTo for all money comparisons ("is the payment ≥ the invoice?"), and never use BigDecimal as a HashMap key or in a HashSet unless you've normalized scale first (e.g. with stripTrailingZeros() — with care, since it can also produce scale surprises of its own).

Worked Example: Invoice Line-Item Total

Putting it together — an invoice calculator with timestamps and exact money:

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.ZoneId;

public class Invoice {
    public static void main(String[] args) {
        Instant issuedAt = Instant.now();                      // stored in UTC
        BigDecimal unitPrice = new BigDecimal("249.95");
        BigDecimal quantity  = new BigDecimal("4");
        BigDecimal discount  = new BigDecimal("0.10");         // 10% off

        BigDecimal subtotal  = unitPrice.multiply(quantity);   // 999.80
        BigDecimal discountAmt = subtotal.multiply(discount)
                .setScale(2, RoundingMode.HALF_UP);            // 99.98
        BigDecimal taxable   = subtotal.subtract(discountAmt); // 899.82
        BigDecimal tax       = taxable.multiply(new BigDecimal("0.0825"))
                .setScale(2, RoundingMode.HALF_UP);            // 74.24
        BigDecimal total     = taxable.add(tax);               // 974.06

        System.out.println("Issued: " + issuedAt
                .atZone(ZoneId.of("America/Denver")));
        System.out.println("Subtotal: " + subtotal);          // 999.80
        System.out.println("Discount: -" + discountAmt);      // 99.98
        System.out.println("Tax:      " + tax);               // 74.24
        System.out.println("Total:    " + total);             // 974.06
    }
}

Every amount is exact to the cent, the timestamp is zone-safe, and the display converts to the customer's zone at render time. This pattern — Instant for time, BigDecimal for money, scale and rounding at each boundary — carries straight into Spring and JPA later, where timestamps map to Instant columns and monetary fields map to DECIMAL/BigDecimal.

Quick Recap

  • LocalDate/LocalDateTime = local civil time, no zone. Instant = the point on the timeline you store. ZonedDateTime = what you display.
  • Zones are rules; offsets are numbers. Use region ZoneIds like America/Denver, never hardcoded offsets.
  • Duration = exact seconds (timeouts). Period = calendar time (subscriptions). DateTimeFormatter = thread-safe formatting.
  • Never double for money. new BigDecimal("19.99"), not new BigDecimal(19.99). setScale(2, HALF_UP) at money boundaries. compareTo, not equals, for comparisons.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number