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 Pattern | Operation | Notes |
|---|---|---|---|
| AP1 | Get device metadata | GetItem | Device info, firmware, location |
| AP2 | List devices by status | Query (GSI1) | Fleet management - active, inactive, maintenance |
| AP3 | Get readings for a device in a time range | Query | Dashboard: “last hour of temperature data” |
| AP4 | Get the latest reading for a device | Query | Current device status |
| AP5 | Get alerts for a device | Query | Device alert history |
| AP6 | Get all critical alerts across devices | Query (GSI1) | Operations dashboard |
| AP7 | Get hourly/daily aggregations for a device | Query | Charts: “temperature over the last 7 days” |
| AP8 | Write a new reading (with TTL) | PutItem | Incoming 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

Table design
Primary key structure
| Entity | PK | SK | TTL | Purpose |
|---|---|---|---|---|
| Device | DEVICE#<deviceId> | #METADATA | - | Device info |
| Reading | DEVICE#<deviceId> | READING#<timestamp> | 30 days | Raw telemetry |
| Alert | DEVICE#<deviceId> | ALERT#<alertId> | - | Triggered alarms |
| Aggregation | DEVICE#<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
| GSI | PK | SK | Purpose |
|---|---|---|---|
| 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#).

Sample data
| pk | sk | gsi1pk | gsi1sk | ttl | Entity Data |
|---|---|---|---|---|---|
DEVICE#sensor_01 | #METADATA | DEVICE_STATUS#active | DEVICE#sensor_01 | - | { name: "Warehouse Temp A", type: "sensor", firmware: "2.1.0" } |
DEVICE#sensor_01 | READING#2026-04-20T14:00:00Z | - | - | 1750435200 | { temperature: 22.4, humidity: 45, battery: 87 } |
DEVICE#sensor_01 | READING#2026-04-20T14:01:00Z | - | - | 1750435260 | { temperature: 22.5, humidity: 44, battery: 87 } |
DEVICE#sensor_01 | ALERT#01HW2F3G4H... | ALERT_SEVERITY#critical | ALERT#01HW2F3G4H... | - | { message: "Temperature exceeded 30°C", resolvedAt: null } |
DEVICE#sensor_01 | AGG#hourly#2026-04-20T14:00:00Z | - | - | - | { avgTemp: 22.8, maxTemp: 23.1, minTemp: 22.4, readingCount: 60 } |
DEVICE#sensor_01 | AGG#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:
- Stream event fires with the new reading
- Lambda reads the reading’s timestamp, determines which hourly and daily buckets it belongs to
- 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.
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.