Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Step-by-step instructions for designing table schemas and setting up TimescaleDB with hypertables, indexes, compression, retention policies, and continuous aggregates. Instructions for selecting: partition columns, segment_by columns, order_by columns, chunk time interval, real-time aggregation.
.claude/skills/microck-setup-timescaledb-hypertables/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 108% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 140% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 111% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 109% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 181% | 0% |
Instructions for insert-heavy data patterns where data is inserted but rarely changed:
sqlCREATE TABLE your_table_name ( timestamp TIMESTAMPTZ NOT NULL, entity_id TEXT NOT NULL, -- device_id, user_id, symbol, etc. category TEXT, -- sensor_type, event_type, asset_class, etc. value_1 DOUBLE PRECISION, -- price, temperature, latency, etc. value_2 DOUBLE PRECISION, -- volume, humidity, throughput, etc. value_3 INTEGER, -- count, status, level, etc. metadata JSONB -- flexible additional data ) WITH ( tsdb.hypertable, tsdb.partition_column='timestamp', tsdb.enable_columnstore=true, -- Disable if table has vector columns tsdb.segmentby='entity_id', -- See selection guide below tsdb.orderby='timestamp DESC', -- See selection guide below tsdb.sparse_index='minmax(value_1),minmax(value_2),minmax(value_3)' -- see selection guide below );
Must be time-based (TIMESTAMP/TIMESTAMPTZ/DATE) or integer (INT/BIGINT) with good temporal/sequential distribution.
Common patterns:
timestamp, event_time, measured_atevent_time, created_at, logged_atcreated_at, transaction_time, processed_atid (auto-increment when no timestamp), sequence_numbercreated_at, inserted_at, idLess ideal: ingested_at (when data entered system - use only if it's your primary query dimension) Avoid: updated_at (breaks time ordering unless it's primary query dimension)
PREFER SINGLE COLUMN - multi-column rarely optimal. Multi-column can only work for highly correlated columns (e.g., metric_name + metric_type) with sufficient row density.
Requirements:
Examples:
device_idsymbolservice_name, service_name, metric_type (if sufficient row density), metric_name, metric_type (if sufficient row density)user_id if sufficient row density, otherwise session_idproduct_id if sufficient row density, otherwise category_idRow density guidelines:
Query pattern drives choice:
sqlSELECT * FROM table WHERE entity_id = 'X' AND timestamp > ... -- ↳ segment_by: entity_id (if >100 rows per chunk)
Avoid: timestamps, unique IDs, low-density columns (<100 rows/value/chunk), columns rarely used in filtering
Creates natural time-series progression when combined with segment_by for optimal compression.
Most common: timestamp DESC
Examples:
timestamp DESCmetric_name, timestamp DESC (if metric_name has too low density for segment_by)user_id, timestamp DESC (user_id has too low density for segment_by)Alternative patterns:
sequence_id DESC for event streams with sequence numberstimestamp DESC, event_order DESC for sub-ordering within same timestampLow-density column handling: If a column has <100 rows per chunk (too low for segment_by), prepend it to order_by:
metric_name has 20 rows/chunk → use segment_by='service_name', order_by='metric_name, timestamp DESC'Good test: ordering created by (segment_by_column, order_by_column) should form a natural time-series progression. Values close to each other in the progression should be similar.
Avoid in order_by: random columns, columns with high variance between adjacent rows, columns unrelated to segment_by
Sparse indexes enable query filtering on compressed data without decompression. Store metadata per batch (~1000 rows) to eliminate batches that don't match query predicates.
Types:
Use minmax for: price, temperature, measurement, timestamp (range filtering)
Use for:
created_at, minmax on updated_at is useful).Avoid: rarely filtered columns.
IMPORTANT: NEVER index columns in segmentby or orderby. Orderby columns will always have minmax indexes without any configuration.
Configuration: The format is a comma-separated list of type_of_index(column_name).
sqlALTER TABLE table_name SET ( timescaledb.sparse_index = 'minmax(value_1),minmax(value_2)' );
Explicit configuration available since v2.22.0 (was auto-created since v2.16.0).
Default: 7 days (use if volume unknown, or ask user). Adjust based on volume:
sqlSELECT set_chunk_time_interval('your_table_name', INTERVAL '1 day');
Good test: recent chunk indexes should fit in less than 25% of RAM.
Common index patterns - composite indexes on an id and timestamp:
sqlCREATE INDEX idx_entity_timestamp ON your_table_name (entity_id, timestamp DESC);
Important: Only create indexes you'll actually use - each has maintenance overhead.
Primary key and unique constraints rules: Must include partition column.
Option 1: Composite PK with partition column
sqlALTER TABLE your_table_name ADD PRIMARY KEY (entity_id, timestamp);
Option 2: Single-column PK (only if it's the partition column)
sqlCREATE TABLE ... (id BIGINT PRIMARY KEY, ...) WITH (tsdb.partition_column='id');
Option 3: No PK: strict uniqueness is often not required for insert-heavy patterns.
Set after interval for when: data becomes mostly immutable (some updates/backfill OK) AND B-tree indexes aren't needed for queries (less common criterion).
sql-- Adjust 'after' based on update patterns CALL add_columnstore_policy('your_table_name', after => INTERVAL '1 day');
IMPORTANT: Don't guess - ask user or comment out if unknown.
sql-- Example - replace with requirements or comment out SELECT add_retention_policy('your_table_name', INTERVAL '365 days');
Use different aggregation intervals for different uses.
For up-to-the-minute dashboards on high-frequency data.
sqlCREATE MATERIALIZED VIEW your_table_hourly WITH (timescaledb.continuous) AS SELECT time_bucket(INTERVAL '1 hour', timestamp) AS bucket, entity_id, category, COUNT(*) as record_count, AVG(value_1) as avg_value_1, MIN(value_1) as min_value_1, MAX(value_1) as max_value_1, SUM(value_2) as sum_value_2 FROM your_table_name GROUP BY bucket, entity_id, category;
For long-term reporting and analytics.
sqlCREATE MATERIALIZED VIEW your_table_daily WITH (timescaledb.continuous) AS SELECT time_bucket(INTERVAL '1 day', timestamp) AS bucket, entity_id, category, COUNT(*) as record_count, AVG(value_1) as avg_value_1, MIN(value_1) as min_value_1, MAX(value_1) as max_value_1, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY value_1) as median_value_1, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY value_1) as p95_value_1, SUM(value_2) as sum_value_2 FROM your_table_name GROUP BY bucket, entity_id, category;
Set up refresh policies based on your data freshness requirements.
start_offset: Usually omit (refreshes all). Exception: If you don't care about refreshing data older than X (see below). With retention policy on raw data: match the retention policy.
end_offset: Set beyond active update window (e.g., 15 min if data usually arrives within 10 min). Data newer than end_offset won't appear in queries without real-time aggregation. If you don't know your update window, use the size of the time_bucket in the query, but not less than 5 minutes.
schedule_interval: Set to the same value as the end_offset but not more than 1 hour.
Hourly - frequent refresh for dashboards:
sqlSELECT add_continuous_aggregate_policy('your_table_hourly', end_offset => INTERVAL '15 minutes', schedule_interval => INTERVAL '15 minutes');
Daily - less frequent for reports:
sqlSELECT add_continuous_aggregate_policy('your_table_daily', end_offset => INTERVAL '1 hour', schedule_interval => INTERVAL '1 hour');
Use start_offset only if you don't care about refreshing old data Use for high-volume systems where query accuracy on older data doesn't matter:
sql-- the following aggregate can be stale for data older than 7 days -- SELECT add_continuous_aggregate_policy('aggregate_for_last_7_days', -- start_offset => INTERVAL '7 days', -- only refresh last 7 days -- end_offset => INTERVAL '15 minutes', -- schedule_interval => INTERVAL '15 minutes');
IMPORTANT: you MUST set a start_offset to be less than the retention policy on raw data. By default, set the start_offset equal to the retention policy. If the retention policy is commented out, comment out the start_offset as well. like this:
sqlSELECT add_continuous_aggregate_policy('your_table_daily', -- start_offset => INTERVAL '<retention period here>', -- uncomment if retention policy is enabled on the raw data table end_offset => INTERVAL '1 hour', schedule_interval => INTERVAL '1 hour');
Real-time combines materialized + recent raw data at query time. Provides up-to-date results at the cost of higher query latency.
More useful for fine-grained aggregates (e.g., minutely) than coarse ones (e.g., daily/monthly) since large buckets will be mostly incomplete with recent data anyway.
Disabled by default in v2.13+, before that it was enabled by default.
Use when: Need data newer than end_offset, up-to-minute dashboards, can tolerate higher query latency Disable when: Performance critical, refresh policies sufficient, high query volume, missing and stale data for recent data is acceptable
Enable for current results (higher query cost):
sqlALTER MATERIALIZED VIEW your_table_hourly SET (timescaledb.materialized_only = false);
Disable for performance (but with stale results):
sqlALTER MATERIALIZED VIEW your_table_hourly SET (timescaledb.materialized_only = true);
Rule: segment_by = ALL GROUP BY columns except time_bucket, order_by = time_bucket DESC
sql-- Hourly ALTER MATERIALIZED VIEW your_table_hourly SET ( timescaledb.enable_columnstore, timescaledb.segmentby = 'entity_id, category', timescaledb.orderby = 'bucket DESC' ); CALL add_columnstore_policy('your_table_hourly', after => INTERVAL '3 days'); -- Daily ALTER MATERIALIZED VIEW your_table_daily SET ( timescaledb.enable_columnstore, timescaledb.segmentby = 'entity_id, category', timescaledb.orderby = 'bucket DESC' ); CALL add_columnstore_policy('your_table_daily', after => INTERVAL '7 days');
Aggregates are typically kept longer than raw data. IMPORTANT: Don't guess - ask user or you MUST comment out if unknown.
sql-- Example - replace or comment out SELECT add_retention_policy('your_table_hourly', INTERVAL '2 years'); SELECT add_retention_policy('your_table_daily', INTERVAL '5 years');
Index strategy: Analyze WHERE clauses in common queries → Create indexes matching filter columns + time ordering
Pattern: (filter_column, bucket DESC) supports WHERE filter_column = X AND bucket >= Y ORDER BY bucket DESC
Examples:
sqlCREATE INDEX idx_hourly_entity_bucket ON your_table_hourly (entity_id, bucket DESC); CREATE INDEX idx_hourly_category_bucket ON your_table_hourly (category, bucket DESC);
Multi-column filters: Create composite indexes for WHERE entity_id = X AND category = Y:
sqlCREATE INDEX idx_hourly_entity_category_bucket ON your_table_hourly (entity_id, category, bucket DESC);
Important: Only create indexes you'll actually use - each has maintenance overhead.
Only for query patterns where you ALWAYS filter by the space-partition column with expert knowledge and extensive benchmarking. STRONGLY prefer time-only partitioning.
sql-- Check hypertable SELECT * FROM timescaledb_information.hypertables WHERE hypertable_name = 'your_table_name'; -- Check compression SELECT * FROM timescaledb_information.columnstore_settings WHERE hypertable_name LIKE 'your_table_name'; -- Check aggregates SELECT * FROM timescaledb_information.continuous_aggregates; -- Check policies SELECT * FROM timescaledb_information.jobs ORDER BY job_id; -- Monitor chunk information SELECT chunk_name, table_size, compressed_heap_size, compressed_index_size FROM timescaledb_information.chunks WHERE hypertable_name = 'your_table_name';
timescaledb-tune for self-hosting (auto-configured on cloud)TIMESTAMPTZ NOT timestamp>= and < NOT BETWEEN for timestampsTEXT with constraints NOT char(n)/varchar(n)snake_case NOT CamelCaseBIGINT GENERATED ALWAYS AS IDENTITY NOT SERIALBIGINT for IDs by default over INTEGER or SMALLINTDOUBLE PRECISION by default over REAL/FLOATNUMERIC NOT MONEYNOT EXISTS NOT NOT INtime_bucket() or date_trunc() NOT timestamp(0) for truncationDeprecated Parameters → New Parameters:
timescaledb.compress → timescaledb.enable_columnstoretimescaledb.compress_segmentby → timescaledb.segmentbytimescaledb.compress_orderby → timescaledb.orderbyDeprecated Functions → New Functions:
add_compression_policy() → add_columnstore_policy()remove_compression_policy() → remove_columnstore_policy()compress_chunk() → convert_to_columnstore()decompress_chunk() → convert_to_rowstore()Deprecated Views → New Views:
compression_settings → columnstore_settingshypertable_compression_settings → hypertable_columnstore_settingschunk_compression_settings → chunk_columnstore_settingsDeprecated Stats Functions → New Stats Functions:
hypertable_compression_stats() → hypertable_columnstore_stats()chunk_compression_stats() → chunk_columnstore_stats()| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 16,832 | 12,065 | -28% | 1 | 1 | 0% | 3,366 | 7,015 | +108% | 0 | 0 | — |
case-02 | fail→pass | 15,382 | 12,058 | -22% | 1 | 1 | 0% | 2,964 | 7,121 | +140% | 0 | 0 | — |
case-13 | pass→pass | 3,628 | 2,889 | -20% | 1 | 1 | 0% | 618 | 4,859 | +686% | 0 | 0 | — |
case-03 | fail→pass | 15,067 | 10,517 | -30% | 1 | 1 | 0% | 3,103 | 6,548 | +111% | 0 | 0 | — |
case-04 | pass→pass | 16,722 | 15,339 | -8% | 1 | 1 | 0% | 2,786 | 7,362 | +164% | 0 | 0 | — |
case-05 | pass→pass | 3,742 | 5,929 | +58% | 1 | 1 | 0% | 721 | 5,511 | +664% | 0 | 0 | — |
case-06 | pass→pass | 12,012 | 10,202 | -15% | 1 | 1 | 0% | 2,273 | 6,403 | +182% | 0 | 0 | — |
case-07 | fail→pass | 15,711 | 7,666 | -51% | 1 | 1 | 0% | 2,777 | 5,799 | +109% | 0 | 0 | — |
case-08 | fail→pass | 11,605 | 5,002 | -57% | 1 | 1 | 0% | 1,891 | 5,314 | +181% | 0 | 0 | — |
case-09 | pass→pass | 12,304 | 4,275 | -65% | 1 | 1 | 0% | 2,042 | 5,107 | +150% | 0 | 0 | — |
case-10 | pass→pass | 5,945 | 3,959 | -33% | 1 | 1 | 0% | 1,222 | 5,226 | +328% | 0 | 0 | — |
case-11 | fail→pass | 12,428 | 5,615 | -55% | 1 | 1 | 0% | 2,135 | 5,368 | +151% | 0 | 0 | — |
case-12 | pass→pass | 5,634 | 5,086 | -10% | 1 | 1 | 0% | 970 | 5,200 | +436% | 0 | 0 | — |
case-14 | fail→pass | 15,961 | 2,673 | -83% | 1 | 1 | 0% | 2,925 | 4,855 | +66% | 0 | 0 | — |
case-15 | fail→pass | 14,407 | 3,615 | -75% | 1 | 1 | 0% | 2,621 | 5,064 | +93% | 0 | 0 | — |
case-16 | pass→pass | 4,027 | 3,378 | -16% | 1 | 1 | 0% | 652 | 4,818 | +639% | 0 | 0 | — |
case-17 | pass→pass | 8,279 | 3,529 | -57% | 1 | 1 | 0% | 1,269 | 5,012 | +295% | 0 | 0 | — |
case-18 | pass→pass | 11,066 | 4,635 | -58% | 1 | 1 | 0% | 1,697 | 5,089 | +200% | 0 | 0 | — |
case-19 | fail→pass | 8,618 | 4,138 | -52% | 1 | 1 | 0% | 1,637 | 5,099 | +211% | 0 | 0 | — |
case-20 | pass→pass | 14,204 | 9,344 | -34% | 1 | 1 | 0% | 2,825 | 6,143 | +117% | 0 | 0 | — |
case-21 | pass→pass | 16,414 | 10,036 | -39% | 1 | 1 | 0% | 2,659 | 5,981 | +125% | 0 | 0 | — |
case-22 | pass→pass | 11,726 | 5,286 | -55% | 1 | 1 | 0% | 2,023 | 5,407 | +167% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +41 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.