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.
- Money — using floating-point types for currency.
- Time — storing or sending times without explicit timezone information.
- 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:
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.
// 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.
✓ "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).
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:
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