Skip to content

DynamoDB Single-Table Pattern: IoT Time-Series

IoT data fits DynamoDB’s key-value model almost perfectly. Devices generate readings at a known rate. Queries are almost always scoped to a single device within a time window. The access patterns are predictable.

The hard part is the data lifecycle. A fleet of 1,000 sensors reporting every minute generates 1.4 million records per day. Without TTL and pre-computed aggregations, you’re paying to store data you’ll never query and scanning millions of raw rows for dashboard charts.

This pattern handles device metadata, raw readings with TTL, alerts, and pre-computed aggregations in a single table.

Access patterns

#Access PatternOperationNotes
AP1Get device metadataGetItemDevice info, firmware, location
AP2List devices by statusQuery (GSI1)Fleet management - active, inactive, maintenance
AP3Get readings for a device in a time rangeQueryDashboard: “last hour of temperature data”
AP4Get the latest reading for a deviceQueryCurrent device status
AP5Get alerts for a deviceQueryDevice alert history
AP6Get all critical alerts across devicesQuery (GSI1)Operations dashboard
AP7Get hourly/daily aggregations for a deviceQueryCharts: “temperature over the last 7 days”
AP8Write a new reading (with TTL)PutItemIncoming telemetry with automatic expiry

Eight access patterns. Four entity types. One table, one GSI.

Entities

  • Device: the physical sensor or gateway
  • Reading: a single telemetry data point from a device
  • Alert: a triggered alarm when a reading exceeds a threshold
  • Aggregation: pre-computed hourly or daily summary for dashboard queries
IoT Time-Series entity diagram showing Device, Reading, Alert, Aggregation relationships
4 entities · 1 GSI · readings expire via TTL, aggregations persist

Table design

Primary key structure

EntityPKSKTTLPurpose
DeviceDEVICE#<deviceId>#METADATA-Device info
ReadingDEVICE#<deviceId>READING#<timestamp>30 daysRaw telemetry
AlertDEVICE#<deviceId>ALERT#<alertId>-Triggered alarms
AggregationDEVICE#<deviceId>AGG#<period>#<timestamp>-Hourly/daily summaries

Partition by device, sort by time. This is the fundamental IoT pattern. Every query is scoped to a device and a time window. The partition key isolates each device’s data; the sort key enables range queries on time.

Readings use ISO timestamps, not ULIDs. Unlike the E-Commerce Orders pattern, readings don’t need unique IDs for direct lookup. The device + timestamp combination is sufficient. If a device reports multiple readings per second, add millisecond precision to the timestamp.

TTL on raw readings. Set the ttl attribute to a Unix epoch timestamp 30 days in the future. DynamoDB automatically deletes expired items within 48 hours of the TTL value. Raw readings expire; aggregations persist indefinitely. TTL is one of the most underused DynamoDB cost optimization levers - it keeps table size bounded at no additional write cost.

Aggregations use composite sort keys. AGG#hourly#2026-04-20T14:00:00Z and AGG#daily#2026-04-20 sort naturally. Query all hourly aggregations for a device with SK begins_with AGG#hourly#, or all daily aggregations with SK begins_with AGG#daily#.

GSI design

GSIPKSKPurpose
GSI1 (Device)DEVICE_STATUS#<status>DEVICE#<deviceId>Fleet management by status
GSI1 (Alert)ALERT_SEVERITY#<severity>ALERT#<alertId>Critical alerts across all devices

One overloaded GSI serves two cross-device access patterns. The prefixes don’t collide (DEVICE_STATUS# vs ALERT_SEVERITY#).

IoT Time-Series DynamoDB schema: Entity, PK, SK, TTL columns for Device, Reading, Alert, Aggregation
PK/SK structure for Device, Reading, Alert, and Aggregation — all in the device partition, readings expire via TTL

Sample data

pkskgsi1pkgsi1skttlEntity Data
DEVICE#sensor_01#METADATADEVICE_STATUS#activeDEVICE#sensor_01-{ name: "Warehouse Temp A", type: "sensor", firmware: "2.1.0" }
DEVICE#sensor_01READING#2026-04-20T14:00:00Z--1750435200{ temperature: 22.4, humidity: 45, battery: 87 }
DEVICE#sensor_01READING#2026-04-20T14:01:00Z--1750435260{ temperature: 22.5, humidity: 44, battery: 87 }
DEVICE#sensor_01ALERT#01HW2F3G4H...ALERT_SEVERITY#criticalALERT#01HW2F3G4H...-{ message: "Temperature exceeded 30°C", resolvedAt: null }
DEVICE#sensor_01AGG#hourly#2026-04-20T14:00:00Z---{ avgTemp: 22.8, maxTemp: 23.1, minTemp: 22.4, readingCount: 60 }
DEVICE#sensor_01AGG#daily#2026-04-20---{ avgTemp: 22.1, maxTemp: 24.5, minTemp: 20.8, readingCount: 1440 }

Resolving each access pattern

AP1 - Get device metadata:

GetItem(pk=DEVICE#sensor_01, sk=#METADATA)

AP2 - List active devices:

Query(GSI1, gsi1pk=DEVICE_STATUS#active)

AP3 - Readings for a device in the last hour:

Query(pk=DEVICE#sensor_01, 
  sk BETWEEN READING#2026-04-20T13:00:00Z AND READING#2026-04-20T14:00:00Z)

AP4 - Latest reading:

Query(pk=DEVICE#sensor_01, sk begins_with READING#, ScanIndexForward=false, Limit=1)

AP5 - Alerts for a device:

Query(pk=DEVICE#sensor_01, sk begins_with ALERT#, ScanIndexForward=false)

AP6 - All critical alerts:

Query(GSI1, gsi1pk=ALERT_SEVERITY#critical, ScanIndexForward=false)

AP7 - Daily aggregations for the last 7 days:

Query(pk=DEVICE#sensor_01, 
  sk BETWEEN AGG#daily#2026-04-13 AND AGG#daily#2026-04-20)

AP8 - Write a reading with TTL:

const THIRTY_DAYS = 30 * 24 * 60 * 60;
await ReadingEntity.put({
  deviceId: "sensor_01",
  timestamp: new Date().toISOString(),
  temperature: 22.4,
  humidity: 45,
  battery: 87,
  ttl: Math.floor(Date.now() / 1000) + THIRTY_DAYS,
}).go();

Computing aggregations

Aggregations are computed by a DynamoDB Streams-triggered Lambda. When a new reading arrives:

  1. Stream event fires with the new reading
  2. Lambda reads the reading’s timestamp, determines which hourly and daily buckets it belongs to
  3. Lambda updates the aggregation record using atomic operations:
await AggregationEntity.update({
  deviceId: reading.deviceId,
  period: "hourly",
  timestamp: hourStart,  // e.g., "2026-04-20T14:00:00Z"
}).add({ readingCount: 1 })
  .set({ 
    avgTemp: newAvg,  // computed from running total
    maxTemp: Math.max(existing.maxTemp, reading.temperature),
    minTemp: Math.min(existing.minTemp, reading.temperature),
  }).go();

This is eventually consistent - the aggregation updates within seconds of the reading arriving. For dashboards, that’s more than fast enough.

ElectroDB entity definitions

export const DeviceEntity = new Entity({
  model: { entity: "device", version: "1", service: "iot" },
  attributes: {
    deviceId:   { type: "string", required: true },
    name:       { type: "string", required: true },
    type:       { type: "string", required: true, enum: ["sensor", "gateway", "actuator"] },
    status:     { type: "string", required: true, default: "active" },
    location:   { type: "string" },
    firmware:   { type: "string" },
    lastSeenAt: { type: "string" },
    createdAt: {
      type: "string", required: true,
      default: () => new Date().toISOString(), readOnly: true,
    },
    updatedAt: {
      type: "string", required: true,
      default: () => new Date().toISOString(),
      set: () => new Date().toISOString(), watch: "*",
    },
  },
  indexes: {
    primary: {
      pk: { field: "pk", composite: ["deviceId"], template: "DEVICE#${deviceId}" },
      sk: { field: "sk", composite: [],           template: "#METADATA" },
    },
    byStatus: {
      index: "GSI1",
      pk: { field: "gsi1pk", composite: ["status"], template: "DEVICE_STATUS#${status}" },
      sk: { field: "gsi1sk", composite: ["deviceId"], template: "DEVICE#${deviceId}" },
    },
  },
}, { client, table });

Why this design

TTL is the data lifecycle strategy. Raw readings auto-expire after 30 days; aggregations persist forever. Your table size is bounded at most 30 days of raw data plus the entire aggregation history, with no batch delete jobs or cron tasks.

Aggregations avoid expensive historical queries. “Show me the average temperature for the last 6 months” would require reading millions of raw readings. With daily aggregation records, it’s 180 items, a single Query. The Analytics Events pattern applies the same aggregation technique at higher write throughput with shard-based ingestion.

The DEVICE_STATUS# GSI key will get hot at fleet scale. If 90% of your devices are “active,” that partition handles most of the fleet management queries. At 10,000+ devices, consider sharding the status key (same technique as the E-Commerce status GSI).

I chose not to use time-based partitioning. Some IoT designs use DEVICE#sensor_01#2026-04 as the PK to split a device’s data by month. This avoids unbounded partition growth but makes cross-month queries harder. For most IoT applications with TTL-expired readings, the partition stays manageable without it.

Design this visually → coming soon

The partition-per-device structure and the TTL/aggregation split are the two decisions that matter most here. On a canvas where you can see the reading items fading out at the 30-day mark and the aggregation items persisting, the data lifecycle strategy becomes intuitive. That’s what I’m building at singletable.dev.

Join the waitlist →


Pattern #5 of 10 in the SingleTable pattern library.

This is pattern #5 in the singletable.dev pattern library. Previously: Chat/Messaging - conversations, paginated messages, and read receipts. Next up: the Booking/Scheduling pattern - time slot management with condition-based double-booking prevention.

Tejovanth N

These patterns come from real apps - rasika.life, rekha.app, rrmstays - all running single-table DynamoDB with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.