Home
Backend from First Principles / Module 32 — Money, Time & Identity

Money, Time & Identity

The three things that bite every backend engineer eventually. Cents, UTC, UUIDs — get them right early.


Three Categories of Bug You'll Definitely Make

If you write backend code for long enough, three categories of bug will catch you. Not maybe — definitely. They've caught everyone.

  1. Money — using floating-point types for currency.
  2. Time — storing or sending times without explicit timezone information.
  3. Identity — using auto-incrementing integers as IDs in distributed systems.

Each of these has a clear right answer. Each gets gotten wrong by the majority of new backend engineers. Each causes production incidents that are expensive to clean up. The good news: once you know the rules, you can prevent them all forever in two days of work.

This module is short and direct. Internalize the rules. Save your future self enormous pain.


Money — Never Use Floats

Floating point numbers cannot exactly represent most decimal values. The classic surprise:

JavaScript
0.1 + 0.2
// 0.30000000000000004

This is not a bug. It's how IEEE 754 floats work — they're binary fractions, and most decimals don't terminate in binary, just like 1/3 doesn't terminate in decimal.

If you store $9.99 as a float and add 100 such items, you might get $998.9999999999998. Then you round it. Then you charge the customer. Now your books don't reconcile.

The rules:

1. Store money as INTEGER CENTS, not as decimal dollars. $9.99 → 999. Multiplication, addition, comparison — all exact.

2. If your database has a DECIMAL or NUMERIC type with explicit precision, that's fine too. PostgreSQL DECIMAL(12, 2) is exact and human-readable. Just NEVER use FLOAT or DOUBLE for money.

3. Always store the currency alongside the amount. { amount: 999, currency: "USD" } not { amount: 999 }. International apps need this; even single-currency apps benefit because adding a second currency later is hard if amounts are dimensionless.

4. Convert to display format at the edges only. Inside your system, deal in minor units. Format to "$9.99" for display only when the value leaves your system as text.

5. For multi-currency arithmetic, be explicit about exchange rates AND the moment they were captured. Currencies fluctuate; storing only the converted amount loses information.

JavaScript
// Bad
const price = 9.99;
const total = price * 100;  // 999.0000000000001

// Good
const priceMinorUnits = 999;        // cents
const currency = "USD";
const total = priceMinorUnits * 100; // 99900 — exact

This single rule prevents the most expensive class of backend bug: charges that don't match invoices. Apply it religiously.


Time — UTC, ISO 8601, Always

Timezones are the second easiest way to corrupt your data. The rules are simple but have to be applied everywhere.

Rule 1: Store all times in UTC. Always.

Your database column is TIMESTAMPTZ (PostgreSQL), TIMESTAMP WITH TIME ZONE standard SQL, or equivalent. The stored value is always UTC. Period.

Rule 2: Convert to local time only for display.

When the user sees "5:30 PM," that's a presentation-layer concern. The database has UTC. The API returns UTC. The browser converts to user-local for display.

Rule 3: Send times in ISO 8601 format with explicit timezone.

Text
✓  "2024-01-15T10:30:00Z"            ← UTC, marked with Z
✓  "2024-01-15T10:30:00+00:00"        ← UTC, explicit offset
✗  "2024-01-15 10:30:00"              ← ambiguous, no timezone
✗  1705314600                          ← unix timestamp; ambiguous (seconds? ms?)
✗  "Jan 15, 2024 10:30 AM"            ← human-readable, locale-dependent

If you absolutely must use unix timestamps in an API, document them as "milliseconds since epoch UTC" and stick to it.

Rule 4: Store the user's timezone separately if behavior depends on it.

"Send a daily report at 9 AM" is ambiguous. 9 AM where? Store the user's IANA timezone identifier (America/New_York, Asia/Kolkata) as a separate field, and apply it when scheduling the job.

Rule 5: Test daylight saving time and leap seconds.

The two days a year when DST changes break a depressing amount of code: alarms that fire twice or not at all, schedulers that drift, "yesterday" calculations that span 23 or 25 hours instead of 24. Most languages' DST handling is fine if you use proper datetime libraries (don't roll your own) — but it must be tested.

Rule 6: Never trust the client's clock. The client sends 2024-01-15T10:30:00Z claiming this is when the event happened. Their clock might be wrong by hours. For things that matter (audit logs, security events, ordering), generate timestamps server-side.


Identity — UUIDs, Not Auto-Increment

The instinct: id BIGSERIAL PRIMARY KEY. Every row gets a sequential number. Done.

This works fine for a single-database app. It breaks the moment you go distributed.

Why auto-increment IDs are a problem at scale:

1. They can't be generated client-side. The client has to wait for the database to assign an ID. This means a "create order, then create line items referring to the order ID" workflow needs multiple round trips.

2. They reveal information. Sequential IDs let outsiders enumerate your data: /users/1, /users/2, ... lets an attacker iterate every user. They also leak business intel — "I signed up Tuesday with ID 100,032; on Friday someone signed up with ID 100,047. They got 15 customers in 3 days."

3. They conflict across regions. If you replicate data between regions, two orders created simultaneously in different regions might both want ID #1003. You need coordination.

4. They make merging databases painful. Acquiring a company? Now you have two users tables both starting from ID 1.

The answer: UUIDs (Universally Unique Identifiers).

Text
Standard UUID v4 (random):
550e8400-e29b-41d4-a716-446655440000
            ▲
       128 bits, virtually zero collision probability across the universe

UUID v7 (time-ordered, since 2024):
018d4f6e-7c1c-7000-8a09-1c8b97e3e8c2
            ▲
       The first 48 bits are a millisecond timestamp,
       so v7 UUIDs sort by creation time naturally.

UUID v7 is the modern best choice — random enough to be unguessable, but ordered by creation time so they index well in databases (random UUIDs cause B-tree fragmentation; time-ordered UUIDs don't).

Practical rules:

1. Use UUID for primary keys in any system that might go distributed. Yes, the column is bigger (16 bytes vs 8), but the operational headaches it prevents are enormous.

2. Generate them on the client OR the server — either works. Both can produce valid UUIDs without coordinating.

3. Send IDs as strings in JSON, not numbers. "550e8400-e29b-41d4-a716-446655440000". JSON numbers above 2^53 lose precision; strings always work.

4. Don't expose internal sequential IDs in URLs even if you keep them. If you do use auto-increment IDs internally, give external resources a separate UUID for public references.

5. For really high-throughput systems, look at Snowflake IDs (Twitter), KSUIDs, or ULIDs. They're variants of the same idea: globally unique, time-ordered, generatable without coordination.


Putting It Together

A safe schema for a real production system:

SQL
CREATE TABLE orders (
  -- Identity: UUID v7, time-ordered, no central coordinator needed
  id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),

  -- Money: stored in minor units (cents), with explicit currency
  amount      BIGINT       NOT NULL,         -- cents, integer math is exact
  currency    CHAR(3)      NOT NULL,         -- ISO 4217: USD, EUR, INR, ...

  -- Time: TIMESTAMPTZ ensures UTC storage; default NOW() is UTC
  created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),

  -- Customer time zone, stored separately for scheduling decisions
  customer_id UUID         NOT NULL REFERENCES customers(id),

  -- Idempotency key from Module 31, defends against duplicate POSTs
  idempotency_key TEXT     UNIQUE,

  status      TEXT         NOT NULL CHECK (status IN ('pending','paid','cancelled'))
);

CREATE TABLE customers (
  id              UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  email           TEXT         UNIQUE NOT NULL,
  -- IANA timezone string for "send report at 9 AM their time"
  timezone        TEXT         NOT NULL DEFAULT 'UTC',
  created_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

Three small choices, applied consistently from day one, save you from the most expensive bugs in this profession. Make them defaults in your starter templates and never look back.


⁂ Back to all modules