Guide for creating efficient database indexes in ObjectStack.
ObjectStack automatically creates indexes for:
- Primary keys (
id) - Foreign keys (lookup/master_detail fields)
- Unique constraints
Only declare non-default values. type defaults to 'btree' and unique defaults to false — omit them when using defaults.
| Type | Default? | When to Use | Performance |
|---|---|---|---|
btree |
✅ Yes | Equality and range queries (=, <, >, BETWEEN) |
Excellent |
hash |
No | Exact equality only (=) — rare use case |
Fast for =, poor for ranges |
fulltext |
No | Text search columns (descriptions, notes) | Text search only |
gin |
No | Array / JSONB containment, full-text search | JSONB, arrays, tags |
gist |
No | Geospatial / range types | Location, geometry |
indexes: [
{ fields: ['status', 'created_at'] }, // btree (default)
{ fields: ['email'], unique: true }, // btree + unique
{ fields: ['description'], type: 'fulltext' }, // non-default type
{ fields: ['tags'], type: 'gin' }, // non-default type
{ fields: ['location'], type: 'gist' }, // non-default type
]- Foreign keys — Automatic, but verify
- Filter fields — Columns used in WHERE clauses
- Sort fields — Columns used in ORDER BY
- Unique constraints — Enforce uniqueness at DB level
- Composite filters — Fields commonly filtered together
- Join columns — Non-foreign-key join fields
- Frequent aggregations — GROUP BY columns
- Range queries — Date ranges, numeric ranges
- Partial data — Use partial indexes for subset queries
- Low cardinality — Boolean fields (unless combined with others)
- Rarely queried — Fields almost never filtered/sorted
- High write volume — Every insert/update maintains indexes
- Large text — Full-text index only when needed
- Calculated fields — Index source fields instead
indexes: [
// Most specific first (status), then sort key
{ fields: ['status', 'created_at'] },
// Can satisfy queries like:
// - WHERE status = 'active'
// - WHERE status = 'active' ORDER BY created_at DESC
// - WHERE status = 'active' AND created_at > '2026-01-01'
]indexes: [
// Single column uniqueness
{ fields: ['email'], unique: true },
// Composite uniqueness
{ fields: ['tenant_id', 'username'], unique: true },
]indexes: [
// Only index active records
{
fields: ['created_at'],
partial: "status = 'active'",
},
// Only index non-deleted records
{
fields: ['email'],
unique: true,
partial: "deleted_at IS NULL",
},
]indexes: [
{
fields: ['description', 'notes'],
type: 'fulltext',
},
]indexes: [
// JSONB field
{
fields: ['metadata'],
type: 'gin',
},
// Array field
{
fields: ['tags'],
type: 'gin',
},
]indexes: [
{
fields: ['location'],
type: 'gist',
},
]indexes: [
{ fields: ['status'], type: 'btree', unique: false }, // ❌ Redundant defaults
{ fields: ['email'], type: 'btree', unique: true }, // ❌ Redundant type
]indexes: [
{ fields: ['status'] }, // ✅ btree and unique: false are defaults
{ fields: ['email'], unique: true }, // ✅ btree is default, only specify unique
]indexes: [
{ fields: ['is_active'] }, // ❌ Boolean, low cardinality
{ fields: ['is_deleted'] }, // ❌ Boolean, low cardinality
{ fields: ['is_verified'] }, // ❌ Boolean, low cardinality
{ fields: ['status'] }, // ❌ Already indexed elsewhere
{ fields: ['created_at'] }, // ❌ Already indexed elsewhere
]indexes: [
// Composite for common query pattern
{ fields: ['is_active', 'created_at'] },
// Single index covers multiple queries
{ fields: ['status', 'priority'] },
]indexes: [
// Querying by created_at with status filter
{ fields: ['created_at', 'status'] }, // ❌ Wrong order
]indexes: [
// Status is more selective (filters more), goes first
{ fields: ['status', 'created_at'] }, // ✅ Correct order
]// Index: ['status', 'priority', 'created_at']
// ✅ Can use index
WHERE status = 'active'
WHERE status = 'active' AND priority = 'high'
WHERE status = 'active' AND priority = 'high' ORDER BY created_at
// ❌ Cannot use index efficiently
WHERE priority = 'high' // Skips first column
WHERE created_at > '2026-01-01' // Skips first two columnsComposite indexes are used left-to-right. Querying only the second or third column doesn't use the index.
Place most selective (unique) fields first, then range/sort fields last.
// Good order: selective → range
{ fields: ['tenant_id', 'status', 'created_at'] }
// Bad order: range → selective
{ fields: ['created_at', 'status', 'tenant_id'] }Use partial indexes to index only a subset of rows:
// Only index active records (common query)
{
fields: ['created_at'],
partial: "status = 'active'",
}
// Only index high-value accounts
{
fields: ['annual_revenue'],
partial: "annual_revenue > 1000000",
}
// Only index non-deleted records
{
fields: ['email'],
unique: true,
partial: "deleted_at IS NULL",
}Benefits:
- Smaller index size
- Faster writes (fewer rows to maintain)
- Faster queries (focused data subset)
- ✅ Faster SELECT queries
- ✅ Faster ORDER BY operations
- ✅ Faster JOIN operations
- ✅ Enforce uniqueness at DB level
- ❌ Slower INSERT/UPDATE/DELETE (index maintenance)
- ❌ Increased storage (each index duplicates data)
- ❌ Query planner overhead (more indexes = more choices)
| Table Size | Max Indexes | Reasoning |
|---|---|---|
| < 1K rows | 2-3 | Low volume, indexes may not help |
| 1K - 100K rows | 3-5 | Balance read/write performance |
| 100K - 1M rows | 5-8 | Read optimization critical |
| > 1M rows | 8-12 | Consider partitioning + indexes |
ObjectStack auto-generates index names. To specify custom names:
{
name: 'idx_account_status_created', // Custom name
fields: ['status', 'created_at'],
}Auto-generated pattern: idx_{object}_{field1}_{field2}_{...}
Use database tools to monitor index usage:
-- PostgreSQL: Find unused indexes
SELECT
schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY schemaname, tablename;
-- MySQL: Check index cardinality
SHOW INDEX FROM your_table;- Index foreign keys — Always (automatic in ObjectStack)
- Composite for common queries — Combine frequently filtered columns
- Order matters — Most selective field first
- Partial for subsets — Index only relevant rows
- Unique for constraints — Enforce at DB level
- Monitor usage — Remove unused indexes
- Limit total indexes — Balance read/write performance
- Avoid over-indexing — More indexes ≠ better performance
- Test with production data — Index effectiveness depends on data volume
- Use EXPLAIN — Verify query plans before deploying indexes
// Query: WHERE status = 'active' ORDER BY created_at DESC LIMIT 50
indexes: [
{ fields: ['status', 'created_at'] },
]// Query: WHERE tenant_id = X AND ...
indexes: [
{ fields: ['tenant_id', 'status', 'created_at'] },
]// Query: WHERE description ILIKE '%keyword%'
indexes: [
{ fields: ['description'], type: 'fulltext' },
]// Query: WHERE tags @> ['urgent']
indexes: [
{ fields: ['tags'], type: 'gin' },
]// Query: WHERE ST_DWithin(location, point, distance)
indexes: [
{ fields: ['location'], type: 'gist' },
]