| name | starrocks-table-design |
| description | Design StarRocks tables — choosing table types (Duplicate/Aggregate/Unique/Primary Key), partitioning with date_trunc() expressions, bucketing/distribution, and indexes (prefix, bloom filter, bitmap, ngram). Use when creating or modifying StarRocks table schemas, choosing a key model, setting partition granularity, sizing buckets, or selecting indexes. |
| license | Apache-2.0 |
| metadata | {"author":"Tiansu Yu","version":"1.0"} |
StarRocks Table Design
Design table schemas for StarRocks (a sub-second MPP OLAP database): table types, partitioning, bucketing/distribution, and indexing.
Reference: StarRocks Partitioning Best Practices
When to Use
- Creating or modifying a StarRocks table schema
- Choosing a key model (Duplicate / Aggregate / Unique / Primary Key)
- Deciding partition strategy and granularity
- Sizing bucket counts and choosing the distribution key
- Selecting indexes for filter/lookup performance
Related skills: [starrocks-query-optimization] for verifying partition pruning and JOIN strategies; [starrocks-data-loading] for loading into the tables you design.
Table Types and DDL
Choosing the Right Table Type
1. Duplicate Key Table
Use when: Raw event logging, append-only data, no deduplication needed
CREATE TABLE events_log (
event_time DATETIME NOT NULL,
user_id BIGINT,
event_type VARCHAR(50),
properties JSON
)
DUPLICATE KEY (event_time, user_id)
PARTITION BY date_trunc('day', event_time)
DISTRIBUTED BY HASH(user_id) BUCKETS 32;
Pros: Fastest ingestion, no overhead
Cons: No deduplication, largest storage
2. Aggregate Key Table
Use when: Data can be pre-aggregated during ingestion
CREATE TABLE metrics_daily (
metric_date DATE NOT NULL,
metric_name VARCHAR(100) NOT NULL,
total_count BIGINT SUM,
total_amount DECIMAL(20, 2) SUM,
max_value DECIMAL(20, 2) MAX,
min_value DECIMAL(20, 2) MIN
)
AGGREGATE KEY (metric_date, metric_name)
PARTITION BY date_trunc('day', metric_date)
DISTRIBUTED BY HASH(metric_name) BUCKETS 16;
Pros: Reduced storage, faster queries
Cons: Limited aggregation functions (SUM, MAX, MIN, REPLACE)
3. Unique Key Table
Use when: Need to enforce uniqueness, support updates
CREATE TABLE user_profiles (
user_id BIGINT NOT NULL,
username VARCHAR(100),
email VARCHAR(200),
status VARCHAR(20),
updated_at DATETIME
)
UNIQUE KEY (user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 16
PROPERTIES (
"enable_persistent_index" = "true"
);
Pros: Supports upserts, deduplication
Cons: Higher write latency than DUPLICATE
4. Primary Key Table
Use when: Frequent updates, real-time analytics with mutable data
CREATE TABLE orders_realtime (
order_id BIGINT NOT NULL,
order_time DATETIME NOT NULL,
customer_id BIGINT,
status VARCHAR(50),
amount DECIMAL(18, 2),
updated_at DATETIME
)
PRIMARY KEY (order_id)
PARTITION BY date_trunc('day', order_time)
DISTRIBUTED BY HASH(order_id) BUCKETS 32
PROPERTIES (
"enable_persistent_index" = "true",
"replicated_storage" = "true"
);
Pros: Best update performance, point query optimization
Cons: More memory usage for indexes
Column Type Selection
Best practices:
- Use DATETIME for time columns (not VARCHAR)
- Use DECIMAL for monetary values (not DOUBLE)
- Use BIGINT for IDs (not VARCHAR when possible)
- Use JSON for semi-structured data
- Use ARRAY/MAP/STRUCT for nested data
Example:
CREATE TABLE transactions (
transaction_time DATETIME NOT NULL,
transaction_id BIGINT NOT NULL,
user_id BIGINT,
amount DECIMAL(18, 2),
currency VARCHAR(3),
metadata JSON,
tags ARRAY<VARCHAR(50)>
)
DUPLICATE KEY (transaction_time, transaction_id)
PARTITION BY date_trunc('day', transaction_time)
DISTRIBUTED BY HASH(transaction_id) BUCKETS 32;
Partitioning Strategies
Modern partitioning uses expression-based syntax with automatic partition creation.
Partitioning vs Bucketing - Different Jobs
| Aspect | Partitioning | Bucketing |
|---|
| Primary goal | Coarse-grain data pruning and lifecycle control (TTL, archiving) | Fine-grain parallelism and data locality |
| Planner visibility | FE can skip entire partitions via predicates | Only equality predicates support bucket pruning |
| Lifecycle ops | DROP PARTITION is metadata-only (ideal for GDPR, TTL) | Buckets can't be dropped individually |
| Typical count | 10²-10⁴ per table (days, weeks, tenants) | 10-120 per partition |
| Red flags | >100k partitions causes FE memory issues | >200k tablets per BE; tablets >10GB slow compaction |
When Should You Partition?
| Table Type | Partition? | Typical Key |
|---|
| Fact/event stream | Yes | date_trunc('day', event_time) |
| Huge dimension (billions of rows) | Sometimes | Time or business key change date |
| Small dimension/lookup (<100M rows) | No | Rely on hash distribution only |
Key question: If 80%+ of queries include a time filter, partition on time.
Expression-Based Partitioning (Recommended)
Daily partitions (most common):
CREATE TABLE click_stream (
user_id BIGINT,
event_time DATETIME,
url STRING,
page_views INT
)
DUPLICATE KEY (user_id, event_time)
PARTITION BY date_trunc('day', event_time)
DISTRIBUTED BY HASH(user_id) BUCKETS 32;
Hourly partitions (high-frequency data):
CREATE TABLE iot_sensors (
sensor_id BIGINT,
reading_time DATETIME,
temperature DOUBLE
)
DUPLICATE KEY (sensor_id, reading_time)
PARTITION BY date_trunc('hour', reading_time)
DISTRIBUTED BY HASH(sensor_id) BUCKETS 64;
Monthly partitions (long-term storage):
CREATE TABLE historical_archive (
report_month DATETIME,
account_id BIGINT,
metrics JSON
)
DUPLICATE KEY (report_month, account_id)
PARTITION BY date_trunc('month', report_month)
DISTRIBUTED BY HASH(account_id) BUCKETS 16;
Composite Partitioning (Multi-Tenant)
Pattern A: Time-first (recommended for most SaaS workloads)
Prunes on time, keeps tenant rows co-located:
CREATE TABLE metrics (
tenant_id INT,
dt DATETIME,
metric_name STRING,
value DOUBLE
)
PRIMARY KEY (tenant_id, dt, metric_name)
PARTITION BY date_trunc('day', dt)
DISTRIBUTED BY HASH(tenant_id) BUCKETS 32;
Pattern B: Tenant-first (for large tenants needing isolation)
Enables tenant-specific operations but creates more partitions:
CREATE TABLE activity (
tenant_id INT,
dt DATETIME,
id BIGINT,
event_data JSON
)
DUPLICATE KEY (dt, id)
PARTITION BY tenant_id, date_trunc('month', dt)
DISTRIBUTED BY HASH(id) BUCKETS 32;
Choosing the Partition Key
Priority order:
- Time-first default - If 80%+ of queries include a time filter:
PARTITION BY date_trunc('day', dt)
- Tenant isolation - Add tenant for tenant-level data management:
PARTITION BY tenant_id, date_trunc('day', dt)
- Retention alignment - Partition by the column you'll purge on (enables metadata-only DROP)
- Composite keys - Creates
#tenants × #days partitions - keep total under 100k
Choosing Partition Granularity
| Granularity | Use When | Pros | Cons | Annual Count |
|---|
date_trunc('hour', dt) | >2 tablets/day needed, IoT bursts, hot-spot isolation | Fine-grained pruning, 24 partitions/day | 8,760 partitions/year | High |
date_trunc('day', dt) | Default for most BI/reporting (80% of use cases) | Balanced: 365 partitions/year, simple TTL | Less precise for "last 3h" queries | Medium |
date_trunc('week', dt) | Weekly aggregations, medium-term storage | Lower partition count | Coarser pruning | Low |
date_trunc('month', dt) | Historical archive, long retention (>2 years) | Minimal metadata overhead | Very coarse pruning | Very Low |
Sizing guidelines:
- Each partition: ≤100 GB data
- Each partition: ≤20k tablets (across all replicas)
- Total partitions per table: <100k (critical for FE memory)
- Watch composite partitions: 1000 tenants × 365 days = 365k partitions ⚠️
Mixed granularity (StarRocks 3.4+):
You can merge historical partitions into coarser granularity for better efficiency.
Partition Management
With expression-based partitioning, partitions are created automatically on INSERT.
SHOW PARTITIONS FROM table_name;
ALTER TABLE table_name
DROP PARTITION p20231201;
ALTER TABLE table_name
DROP PARTITION p20231201, p20231202;
TRUNCATE TABLE table_name PARTITION (p20240101);
Partition lifecycle management (TTL):
ALTER TABLE events
SET ("partition_live_number" = "90");
ALTER TABLE events
SET ("partition_retention_condition" = "dt >= CURRENT_DATE() - INTERVAL 3 MONTH");
Partition Pruning Verification
EXPLAIN VERBOSE
SELECT * FROM events
WHERE event_time >= '2024-01-01' AND event_time < '2024-01-07';
EXPLAIN VERBOSE
SELECT * FROM events
WHERE DATE(event_time) = '2024-01-01';
EXPLAIN VERBOSE
SELECT * FROM events
WHERE date_trunc('day', event_time) = '2024-01-01';
Best practice: Always filter using the same expression or direct comparison on the partitioned column.
Bucketing and Distribution
Determining Bucket Count
Formula:
bucket_count = (data_size_per_partition_GB / 2GB) × number_of_BE_nodes
Minimum: 1
Maximum: typically 128-256
Examples:
| Partition Size | BE Nodes | Recommended Buckets | Reasoning |
|---|
| 1 GB | 3 | 8 | Small dataset, min distribution |
| 10 GB | 3 | 16 | ~2GB per bucket |
| 100 GB | 10 | 64 | ~1.5GB per bucket |
| 1 TB | 20 | 128 | ~8GB per bucket |
Distribution Strategies
1. Hash Distribution (Most Common)
DISTRIBUTED BY HASH(user_id) BUCKETS 32;
DISTRIBUTED BY HASH(user_id, event_date) BUCKETS 32;
Choose hash column based on:
- High cardinality (many unique values)
- Frequently used in WHERE/JOIN conditions
- Even distribution (avoid skew)
2. Random Distribution
DISTRIBUTED BY RANDOM BUCKETS 16;
Use when:
- No good hash key available
- Data is already evenly distributed
- Table is small
Colocate Groups (Advanced)
Use for: Frequently joined large tables
CREATE TABLE orders (
order_id BIGINT,
user_id BIGINT,
amount DECIMAL(18, 2)
)
DUPLICATE KEY (order_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 32
PROPERTIES (
"colocate_with" = "user_order_group"
);
CREATE TABLE users (
user_id BIGINT,
username VARCHAR(100)
)
UNIQUE KEY (user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 32
PROPERTIES (
"colocate_with" = "user_order_group"
);
SELECT o.*, u.username
FROM orders o
JOIN users u ON o.user_id = u.user_id;
Requirements:
- Same bucketing column
- Same bucket count
- Same replication strategy
Indexes and Optimization
1. Prefix Index (Automatic)
StarRocks automatically creates a prefix index on the first 36 bytes of DUPLICATE/AGGREGATE/UNIQUE keys.
CREATE TABLE events (
event_time DATETIME NOT NULL,
user_id BIGINT NOT NULL,
event_type VARCHAR(50),
data JSON
)
DUPLICATE KEY (event_time, user_id, event_type)
PARTITION BY date_trunc('day', event_time)
DISTRIBUTED BY HASH(user_id) BUCKETS 32;
Best practice: Put frequently filtered columns first in key definition
2. Bloom Filter Index
CREATE TABLE users (
user_id BIGINT,
email VARCHAR(200),
phone VARCHAR(20),
username VARCHAR(100)
)
UNIQUE KEY (user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 16
PROPERTIES (
"bloom_filter_columns" = "email,phone"
);
ALTER TABLE users
SET ("bloom_filter_columns" = "email,phone");
Use for:
- High-cardinality VARCHAR columns
- Equality filters (
WHERE email = '...') and IN lists
Don't use for:
- Low-cardinality columns
- Range queries
- Numeric columns with range filters
3. Bitmap Index
CREATE INDEX idx_status ON events (status) USING BITMAP;
CREATE TABLE events (
event_time DATETIME,
user_id BIGINT,
status VARCHAR(20),
category VARCHAR(50),
INDEX idx_status (status) USING BITMAP
)
DUPLICATE KEY (event_time, user_id)
PARTITION BY date_trunc('day', event_time)
DISTRIBUTED BY HASH(user_id) BUCKETS 32;
Use for:
- Columns where the filter eliminates the vast majority of rows
- Frequent WHERE/point-query filters
- Common in OLAP dimensions (status, category, region)
4. NGram Bloom Filter (Full-text Search)
ALTER TABLE articles
ADD INDEX idx_content (content) USING NGRAMBF ("gram_num" = "4", "bloom_filter_fpp" = "0.05");
Use for:
- LIKE '%keyword%' searches
- Text search scenarios
Table Design Checklist
Before creating production tables:
Common Anti-Patterns to Avoid
❌ Don't use VARCHAR for time columns that will be partitioned
❌ Don't apply functions to partition columns in WHERE (breaks pruning)
❌ Don't create too many buckets (causes small files)
❌ Don't create too few buckets (causes data skew)
❌ Don't use UNIQUE KEY if you only need deduplication (use DUPLICATE + GROUP BY)
❌ Don't create composite partitions that exceed 100k total partitions