Back to Blog

Working with Time-Series Data: Patterns and Best Practices

February 2, 2026By Timothy Murphy
Working with Time-Series Data: Patterns and Best Practices

Every click, every sensor reading, every transaction - it all happens at a specific moment in time. Time-series data is everywhere, and knowing how to handle it efficiently can make or break your application.

What Is Time-Series Data?

Time-series data is any data that's recorded over time. Each record has a timestamp that tells you when something happened.

Common examples:

  • Application metrics - CPU usage, response times, error rates
  • Financial data - Stock prices, transactions, exchange rates
  • IoT sensors - Temperature readings, motion detection, energy usage
  • User analytics - Page views, clicks, session data
  • Log data - Server logs, application events, audit trails

Designing Your Schema

The Basic Structure

At minimum, you need:

CREATE TABLE metrics (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    timestamp DATETIME NOT NULL,
    metric_name VARCHAR(100) NOT NULL,
    value DECIMAL(15, 4) NOT NULL,
    INDEX idx_timestamp (timestamp),
    INDEX idx_metric_time (metric_name, timestamp)
);

A Better Approach: Wide Tables

For better query performance, consider a wider table design:

CREATE TABLE server_metrics (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    timestamp DATETIME NOT NULL,
    server_id INT NOT NULL,
    cpu_percent DECIMAL(5, 2),
    memory_percent DECIMAL(5, 2),
    disk_io_read BIGINT,
    disk_io_write BIGINT,
    network_in BIGINT,
    network_out BIGINT,
    INDEX idx_server_time (server_id, timestamp)
);

This is faster to query because you don't need to JOIN or pivot data.

Using Tags for Flexibility

Add tags or dimensions for filtering:

CREATE TABLE events (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    timestamp DATETIME NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    source VARCHAR(100),
    environment VARCHAR(20),  -- 'production', 'staging', 'development'
    region VARCHAR(20),       -- 'us-east', 'eu-west', etc.
    value DECIMAL(15, 4),
    metadata JSON,
    INDEX idx_type_time (event_type, timestamp),
    INDEX idx_env_time (environment, timestamp)
);

Essential Query Patterns

Pattern 1: Recent Data

Get the latest readings:

-- Last 24 hours of CPU metrics
SELECT timestamp, cpu_percent
FROM server_metrics
WHERE server_id = 1
  AND timestamp > NOW() - INTERVAL 24 HOUR
ORDER BY timestamp DESC;

Pattern 2: Time Bucketing

Group data into time intervals:

-- Average CPU per hour for the last week
SELECT
    DATE_FORMAT(timestamp, '%Y-%m-%d %H:00') AS hour,
    AVG(cpu_percent) AS avg_cpu,
    MAX(cpu_percent) AS max_cpu,
    MIN(cpu_percent) AS min_cpu
FROM server_metrics
WHERE server_id = 1
  AND timestamp > NOW() - INTERVAL 7 DAY
GROUP BY DATE_FORMAT(timestamp, '%Y-%m-%d %H:00')
ORDER BY hour;

PostgreSQL version:

SELECT
    DATE_TRUNC('hour', timestamp) AS hour,
    AVG(cpu_percent) AS avg_cpu,
    MAX(cpu_percent) AS max_cpu
FROM server_metrics
WHERE server_id = 1
  AND timestamp > NOW() - INTERVAL '7 days'
GROUP BY DATE_TRUNC('hour', timestamp)
ORDER BY hour;

Pattern 3: Moving Averages

Smooth out spiky data:

-- 5-minute moving average (MySQL 8.0+)
SELECT
    timestamp,
    cpu_percent,
    AVG(cpu_percent) OVER (
        ORDER BY timestamp
        ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
    ) AS moving_avg
FROM server_metrics
WHERE server_id = 1
  AND timestamp > NOW() - INTERVAL 1 HOUR;

Pattern 4: Detecting Anomalies

Find values outside normal range:

-- Find CPU spikes above 90%
SELECT timestamp, cpu_percent
FROM server_metrics
WHERE server_id = 1
  AND cpu_percent > 90
  AND timestamp > NOW() - INTERVAL 24 HOUR
ORDER BY cpu_percent DESC;

Pattern 5: Rate of Change

Calculate how fast values are changing:

-- Calculate change rate between readings
SELECT
    timestamp,
    value,
    value - LAG(value) OVER (ORDER BY timestamp) AS change,
    timestamp - LAG(timestamp) OVER (ORDER BY timestamp) AS time_diff
FROM metrics
WHERE metric_name = 'requests_total'
  AND timestamp > NOW() - INTERVAL 1 HOUR;

Pattern 6: Gap Detection

Find missing data:

-- Find gaps larger than 5 minutes
WITH time_diffs AS (
    SELECT
        timestamp,
        LAG(timestamp) OVER (ORDER BY timestamp) AS prev_timestamp,
        TIMESTAMPDIFF(MINUTE, LAG(timestamp) OVER (ORDER BY timestamp), timestamp) AS gap_minutes
    FROM server_metrics
    WHERE server_id = 1
      AND timestamp > NOW() - INTERVAL 24 HOUR
)
SELECT timestamp, prev_timestamp, gap_minutes
FROM time_diffs
WHERE gap_minutes > 5;

Performance Optimization

1. Partition by Time

Split your table into time-based partitions:

CREATE TABLE metrics_partitioned (
    id BIGINT AUTO_INCREMENT,
    timestamp DATETIME NOT NULL,
    metric_name VARCHAR(100) NOT NULL,
    value DECIMAL(15, 4) NOT NULL,
    PRIMARY KEY (id, timestamp)
) PARTITION BY RANGE (TO_DAYS(timestamp)) (
    PARTITION p_2025_12 VALUES LESS THAN (TO_DAYS('2026-01-01')),
    PARTITION p_2026_01 VALUES LESS THAN (TO_DAYS('2026-02-01')),
    PARTITION p_2026_02 VALUES LESS THAN (TO_DAYS('2026-03-01')),
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

Benefits:

  • Queries only scan relevant partitions
  • Easy to delete old data (drop partition)
  • Better cache utilization

2. Use the Right Data Types

-- Use DATETIME instead of VARCHAR for timestamps
timestamp DATETIME NOT NULL  -- Good: 8 bytes, queryable
timestamp VARCHAR(30)        -- Bad: 30 bytes, slower comparisons

-- Use appropriate numeric precision
value DECIMAL(10, 2)  -- Good for money
value FLOAT           -- Good for scientific data (less precision)
value DOUBLE          -- Good for high-precision calculations

3. Batch Your Inserts

Insert multiple rows at once:

-- Slow: Individual inserts
INSERT INTO metrics (timestamp, metric_name, value) VALUES ('2026-01-08 10:00:00', 'cpu', 45.2);
INSERT INTO metrics (timestamp, metric_name, value) VALUES ('2026-01-08 10:00:01', 'cpu', 46.1);

-- Fast: Batch insert
INSERT INTO metrics (timestamp, metric_name, value) VALUES
    ('2026-01-08 10:00:00', 'cpu', 45.2),
    ('2026-01-08 10:00:01', 'cpu', 46.1),
    ('2026-01-08 10:00:02', 'cpu', 44.8),
    ('2026-01-08 10:00:03', 'cpu', 45.5);

4. Create Summary Tables

Pre-aggregate data for faster queries:

-- Create hourly summary table
CREATE TABLE metrics_hourly (
    hour DATETIME NOT NULL,
    metric_name VARCHAR(100) NOT NULL,
    avg_value DECIMAL(15, 4),
    min_value DECIMAL(15, 4),
    max_value DECIMAL(15, 4),
    count INT,
    PRIMARY KEY (hour, metric_name)
);

-- Populate with a scheduled job
INSERT INTO metrics_hourly
SELECT
    DATE_FORMAT(timestamp, '%Y-%m-%d %H:00:00') AS hour,
    metric_name,
    AVG(value),
    MIN(value),
    MAX(value),
    COUNT(*)
FROM metrics
WHERE timestamp >= NOW() - INTERVAL 1 HOUR
GROUP BY DATE_FORMAT(timestamp, '%Y-%m-%d %H:00:00'), metric_name
ON DUPLICATE KEY UPDATE
    avg_value = VALUES(avg_value),
    min_value = VALUES(min_value),
    max_value = VALUES(max_value),
    count = VALUES(count);

Data Retention

Don't keep data forever. Set up automatic cleanup:

-- Delete data older than 90 days
DELETE FROM metrics
WHERE timestamp < NOW() - INTERVAL 90 DAY
LIMIT 10000;  -- Delete in batches to avoid locking

Or with partitioning:

-- Much faster: Drop entire partition
ALTER TABLE metrics_partitioned
DROP PARTITION p_2025_10;

Handling Time Zones

Always store timestamps in UTC:

-- Store in UTC
INSERT INTO events (timestamp, event_type)
VALUES (UTC_TIMESTAMP(), 'user_login');

-- Convert to local time when displaying
SELECT
    CONVERT_TZ(timestamp, 'UTC', 'America/New_York') AS local_time,
    event_type
FROM events;

Common Mistakes to Avoid

1. Using String Timestamps

-- Bad: Stored as string
timestamp VARCHAR(30) DEFAULT '2026-01-08 10:00:00'

-- Good: Proper datetime type
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP

2. Missing Time Index

-- Always index your timestamp column
CREATE INDEX idx_timestamp ON metrics(timestamp);

-- For filtered queries, use composite index
CREATE INDEX idx_metric_time ON metrics(metric_name, timestamp);

3. Querying Without Time Bounds

-- Bad: Scans entire table
SELECT AVG(value) FROM metrics WHERE metric_name = 'cpu';

-- Good: Limited time range
SELECT AVG(value) FROM metrics
WHERE metric_name = 'cpu'
  AND timestamp > NOW() - INTERVAL 1 DAY;

4. Not Using Appropriate Precision

-- Too precise for most use cases
timestamp DATETIME(6)  -- Microsecond precision

-- Usually sufficient
timestamp DATETIME     -- Second precision

When to Consider Specialized Solutions

If your time-series needs outgrow traditional databases, consider:

  • TimescaleDB - PostgreSQL extension optimized for time-series
  • InfluxDB - Purpose-built time-series database
  • Prometheus - Great for metrics and monitoring
  • ClickHouse - Column-oriented, excellent for analytics

Signs you might need a specialized solution:

  • Millions of data points per day
  • Sub-second query requirements on large datasets
  • Complex time-based aggregations at scale

Conclusion

Time-series data is unique, and treating it right pays off in performance and maintainability. Start with good schema design, use appropriate indexes, and implement data retention from day one.

The patterns covered here will handle most use cases. As you scale, consider partitioning and summary tables before jumping to specialized databases.

Ready to Explore Your Time-Series Data?

LunoDB makes it easy to query and visualize time-series data with built-in chart views and date range filters. Download LunoDB and start making sense of your timestamped data.