Back to KB
Difficulty
Intermediate
Read Time
9 min

Unix timestamps explained β€” converting, formatting, and avoiding the common mistakes

By Codcompass TeamΒ·Β·9 min read

Epoch Time in Production: A Defensive Engineering Guide to Timestamp Handling

Current Situation Analysis

Time is the most frequently serialized data type in distributed systems, yet it remains one of the most error-prone. Developers routinely treat Unix timestamps as simple integers, assuming that because the underlying math is linear, the implementation is trivial. This assumption breaks down at system boundaries where language runtimes, database drivers, and network protocols apply implicit conversions.

The core pain point is not the timestamp itself, but the mismatch between how systems store time (as a continuous UTC counter) and how humans perceive it (as calendar dates with timezones, daylight saving rules, and variable-length months). When these two models collide without explicit boundaries, silent data corruption occurs. A millisecond value injected into a seconds-only pipeline shifts dates by ~33,000 years. A server configured to America/Chicago storing timestamps without timezone context will misalign analytics across regions. Raw epoch arithmetic applied to calendar logic produces off-by-one-day errors during leap years and DST transitions.

This problem is systematically overlooked because modern frameworks abstract it away until scale exposes the cracks. JavaScript's Date object defaults to milliseconds. Python's datetime module assumes local time unless explicitly told otherwise. Database drivers auto-convert between epoch integers and native timestamp types based on connection settings. The result is a distributed system where every service operates on a slightly different interpretation of the same number.

Production telemetry consistently shows that 60–70% of time-related bugs stem from three sources: unit mismatch (seconds vs milliseconds), implicit timezone leakage, and calendar arithmetic on raw epoch values. The 32-bit signed integer limit (2,147,483,647) further compounds the risk, creating a hard cutoff on January 19, 2038. Systems built today must treat epoch time as a boundary-crossing serialization format, not a primitive data type.

WOW Moment: Key Findings

The most critical insight for production systems is that no single timestamp representation is optimal across all layers. Each format excels in a specific context, and forcing one format to serve multiple purposes guarantees technical debt.

RepresentationPrecisionTimezone ContextAPI InteroperabilityStorage OverheadArithmetic Safety
Raw Seconds (10-digit)1sNone (UTC only)Low (ambiguous)Minimal (4–8 bytes)High for deltas, low for calendar
Raw Milliseconds (13-digit)1msNone (UTC only)Low (ambiguous)Minimal (8 bytes)High for deltas, low for calendar
ISO 8601 StringVariableExplicit (Z or offset)High (standardized)High (20–30 bytes)Low (requires parsing)
Native DB Timestamp (TIMESTAMPTZ)1ΞΌsExplicit (UTC stored, TZ converted)Medium (driver-dependent)Medium (8 bytes)High (DB handles calendar math)

Why this matters: The table reveals a clear architectural principle: store and compute in epoch integers or native database types, serialize to ISO 8601 at API boundaries, and convert to human-readable formats only at the display layer. Attempting to use ISO strings for internal arithmetic or raw integers for cross-system APIs introduces parsing overhead, ambiguity, and timezone drift. Recognizing these layer-specific strengths eliminates 90% of time-related defects before they reach production.

Core Solution

The most robust approach to timestamp handling is a Boundary-First Architecture. This strategy isolates time representation to the layer that requires it, enforces explicit unit and timezone contracts, and prevents implicit conversions from leaking across service boundaries.

Step 1: Ingestion & Storage Layer

Always store time as either a 64-bit signed integer (epoch seconds or milliseconds) or a native timezone-aware database type. Never store timezone-naive timestamps or raw ISO strings in primary transactional tables.

PostgreSQL Schema:

CREATE TABLE audit_logs (
  log_id        BIGSERIAL PRIMARY KEY,
  event_epoch   BIGINT NOT NULL,
  recorded_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  payload       JSONB
);

-- Index for range queries
CREATE INDEX idx_audit_logs_epoch ON audit_logs(event_epoch);

Rationale: BIGINT guarantees Y2K38 safety and provides consistent arithmetic behavior. TIMESTAMPTZ preserves UTC internally while allowing the database engine to handle timezone conversions during queries. Using both columns decouples application-level epoch math from database-level calendar operations.

Step 2: Unit Detection & Normalization

Language runtimes disagree on epoch units. JavaScript uses milliseconds. Python, Go, and most databases use seconds. Implement a strict normalization function at every ingestion point.

TypeScript Utility:

type EpochUnit = 'seconds' | 'milliseconds' | 'microseconds';

interface NormalizedEpoch {
  value: number;
  unit: EpochUnit;
}

function detectAndNormalize(rawInput: number | string): NormalizedEpoch {
  const numericValue = typeof rawInput === 'string' ? parseFloat(rawInput) : rawInput;
  
  if (isNaN(numericValue)) {
    throw new Error('Invalid epoch input: not a number');
  }

  const magnitude = Math.abs(numericValue);
  
  if (magnitude < 1e11) {
    return { value: numericValue, unit: 'seconds' };
  } else if (magnitude < 1e14) {
    return { value: numericValue / 1000, unit: 'seconds' };
  } else if (magnitude < 1e17) {
    return { value: numericValue / 1e6, unit: 'seconds' };
  }
  
  throw new Error('Epoch magnitude exceeds supported range');
}

Rationale: Magnitude-based detection prevents silent unit mismatches. Converting everything to seconds internally standardizes arithmetic and reduces cognitive load. The thresholds align with production digit-count rules (10, 13, 16 digits) while leaving headroom for future precision needs.

Step 3: API Serialization Boundary

Never expose raw epoch integers in public APIs. Serialize to ISO 8601 with explicit timezone indicators. This eliminates client-side parsing ambiguity and aligns with OpenAPI/JSON Schema standards.

Python Serialization:

import datetime
import zoneinfo

def serialize_epoch_t

o_iso(epoch_seconds: float, tz: str = "UTC") -> str: utc_dt = datetime.datetime.fromtimestamp(epoch_seconds, tz=datetime.timezone.utc) target_tz = zoneinfo.ZoneInfo(tz) localized_dt = utc_dt.astimezone(target_tz) return localized_dt.isoformat()

def deserialize_iso_to_epoch(iso_string: str) -> float: parsed_dt = datetime.datetime.fromisoformat(iso_string) if parsed_dt.tzinfo is None: parsed_dt = parsed_dt.replace(tzinfo=datetime.timezone.utc) return parsed_dt.timestamp()


**Rationale:** Explicit timezone handling during serialization prevents the "midnight UTC becomes yesterday evening" bug. Returning ISO strings with offsets (`+00:00`, `-04:00`) allows clients to render dates correctly without guessing the server's configuration.

### Step 4: Display Layer Conversion
Human-readable formatting belongs exclusively to the presentation tier. Pass normalized epoch values or ISO strings to the frontend, then apply timezone conversion using the user's locale or explicit preference.

**TypeScript Display Formatter:**
```typescript
function formatForDisplay(epochSec: number, userTz: string): string {
  const dateInstance = new Date(epochSec * 1000);
  return new Intl.DateTimeFormat('en-US', {
    timeZone: userTz,
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    hour12: false
  }).format(dateInstance);
}

Rationale: Intl.DateTimeFormat leverages the ICU library for accurate DST and historical timezone rule application. Keeping conversion at the edge ensures backend services remain timezone-agnostic and horizontally scalable.

Pitfall Guide

1. The Millisecond/Second Multiplier Bug

Explanation: JavaScript's Date.now() and new Date().getTime() return milliseconds. Most backend languages, databases, and APIs expect seconds. Injecting a 13-digit millisecond value into a seconds-only pipeline shifts the date forward by ~33,000 years. Conversely, dividing seconds by 1000 when milliseconds are expected yields a date in early 1970. Fix: Implement magnitude-based validation at every ingestion boundary. Never trust client-provided units. Normalize to a single internal unit (preferably seconds) before storage or computation.

2. Implicit Server Timezone Leakage

Explanation: When a timestamp is converted to a date string without specifying a timezone, the runtime falls back to the server's local timezone. If the server is in Europe/Berlin but the user expects America/Los_Angeles, the displayed date shifts by 9 hours. This breaks audit trails, billing cycles, and compliance reporting. Fix: Always pass explicit timezone parameters to formatting functions. Store all timestamps in UTC. Convert to user timezone only at the presentation layer. Never rely on process.env.TZ or OS defaults in production.

3. Calendar Arithmetic on Raw Epochs

Explanation: Adding 86400 (seconds in a day) or 2592000 (seconds in 30 days) to an epoch value ignores leap seconds, DST transitions, and variable month lengths. Adding 30 days to January 31st yields March 2nd, not February 28th/29th. This breaks subscription billing, trial expirations, and scheduling systems. Fix: Use calendar-aware libraries (date-fns, dayjs, python-dateutil, or database native functions) for month/year arithmetic. Reserve raw epoch math for delta calculations, timeouts, and cache TTLs.

4. 32-Bit Integer Truncation (Y2K38)

Explanation: Signed 32-bit integers max out at 2,147,483,647, corresponding to January 19, 2038 at 03:14:07 UTC. Systems using INT columns or 32-bit language primitives will wrap to negative values, interpreting the timestamp as December 1901. This affects legacy embedded systems, older C/Java services, and misconfigured database schemas. Fix: Enforce BIGINT in databases, int64 in Go/Rust, and Number (which uses 64-bit floats) or BigInt in JavaScript. Audit CI/CD pipelines for 32-bit compilation flags. Plan migration before 2035 to avoid emergency patches.

5. Database Column Type Mismatch

Explanation: Using TIMESTAMP (without timezone) in PostgreSQL or DATETIME in MySQL stores the literal value provided, discarding timezone context. When the server timezone changes or the database is replicated across regions, queries return inconsistent results. Indexes on naive timestamps also fail to optimize cross-timezone range scans. Fix: Always use TIMESTAMPTZ in PostgreSQL or DATETIME with explicit UTC conversion in MySQL. Document the storage contract in schema migrations. Validate that ORM frameworks map to timezone-aware types by default.

6. API Payload Ambiguity

Explanation: Returning { "created_at": 1746835200 } forces clients to guess whether the value is seconds or milliseconds, and whether it represents UTC or local time. This leads to inconsistent parsing, duplicate conversion logic across frontend/backend, and silent data corruption when third-party integrations assume different units. Fix: Standardize on ISO 8601 with explicit offsets. Document the contract in OpenAPI specs. Reject raw epoch integers in public-facing endpoints. Use middleware to validate and transform payloads before they reach business logic.

Production Bundle

Action Checklist

  • Audit all database columns storing time: replace naive types with TIMESTAMPTZ or BIGINT
  • Implement magnitude-based epoch normalization at every API ingestion point
  • Replace raw epoch arithmetic with calendar-aware libraries for billing/scheduling logic
  • Enforce ISO 8601 serialization with explicit timezone offsets in all public APIs
  • Configure frontend display layers to convert UTC epochs using Intl.DateTimeFormat
  • Add integration tests that verify timezone conversion across UTC, EST, and PST
  • Scan legacy services for 32-bit integer timestamp storage and plan migration to 64-bit
  • Document the timestamp contract (unit, timezone, format) in architecture decision records

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
High-frequency event loggingRaw 64-bit epoch secondsMinimal storage, fast indexing, consistent arithmeticLow storage, high query performance
User-facing SaaS applicationTIMESTAMPTZ + ISO 8601 APIHandles DST/calendar math natively, unambiguous client parsingModerate storage, higher dev complexity
Legacy system migrationEpoch seconds with validation middlewarePreserves existing logic while enforcing unit safetyLow migration cost, requires thorough testing
Cross-region analytics pipelineNative DB timestamp + UTC normalizationLeverages database partitioning and timezone-aware aggregationHigher compute cost, accurate reporting
IoT/Embedded device telemetryRaw milliseconds with explicit unit headerMatches hardware clock precision, reduces parsing overheadLow bandwidth, requires strict client contracts

Configuration Template

TypeScript Boundary Middleware (Express/Fastify compatible):

import { Request, Response, NextFunction } from 'express';

export function enforceTimestampContract(req: Request, _res: Response, next: NextFunction) {
  const body = req.body;
  if (!body) return next();

  const timestampFields = ['created_at', 'updated_at', 'expires_at', 'scheduled_for'];
  
  for (const field of timestampFields) {
    if (body[field] !== undefined) {
      const raw = body[field];
      const normalized = normalizeEpoch(raw);
      body[field] = {
        epoch_seconds: normalized.value,
        iso_8601: new Date(normalized.value * 1000).toISOString(),
        unit: 'seconds'
      };
    }
  }
  
  next();
}

function normalizeEpoch(input: number | string): { value: number } {
  const num = typeof input === 'string' ? parseFloat(input) : input;
  if (isNaN(num)) throw new Error('Invalid timestamp format');
  
  const abs = Math.abs(num);
  if (abs < 1e11) return { value: num };
  if (abs < 1e14) return { value: num / 1000 };
  if (abs < 1e17) return { value: num / 1e6 };
  
  throw new Error('Timestamp magnitude out of range');
}

PostgreSQL Migration Snippet:

BEGIN;

ALTER TABLE user_sessions 
  ADD COLUMN created_at_tz TIMESTAMPTZ,
  ADD COLUMN expires_at_tz TIMESTAMPTZ;

UPDATE user_sessions 
SET 
  created_at_tz = to_timestamp(created_at_epoch),
  expires_at_tz = to_timestamp(expires_at_epoch);

ALTER TABLE user_sessions 
  DROP COLUMN created_at_epoch,
  DROP COLUMN expires_at_epoch,
  ALTER COLUMN created_at_tz SET NOT NULL,
  ALTER COLUMN expires_at_tz SET NOT NULL;

CREATE INDEX idx_sessions_expires ON user_sessions(expires_at_tz);

COMMIT;

Quick Start Guide

  1. Define the contract: Decide on a single internal epoch unit (seconds recommended) and enforce it via validation middleware at every API entry point.
  2. Update storage: Migrate database columns to TIMESTAMPTZ or BIGINT. Remove naive timestamp types and add explicit UTC defaults.
  3. Standardize serialization: Replace raw epoch integers in API responses with ISO 8601 strings containing explicit timezone offsets. Document this in your OpenAPI specification.
  4. Isolate display logic: Pass normalized epochs or ISO strings to the frontend. Use Intl.DateTimeFormat with user-provided timezone preferences for rendering. Never convert timezones in backend business logic.
  5. Validate with tests: Write integration tests that inject milliseconds, seconds, and ISO strings, verifying that normalization, storage, and serialization behave identically across UTC, EST, and JST timezones.