Quick Answer
Database indexes are sorted data structures enabling fast lookups instead of scanning every row. The problem: 50,000 orders query WHERE partner_id = 123 AND order_date >= '2025-01-01' without index = database scans all 50,000 rows sequentially = 5 seconds, 100% CPU. The solution: Composite index on (partner_id, order_date) = database jumps directly to customer 123's orders = 0.1 second, 1% CPU. 50x faster, zero code changes. Index types: (1) Single column: partner_id = fields.Many2one('res.partner', index=True) or CREATE INDEX idx_sale_order_partner ON sale_order(partner_id). (2) Composite (multi-column): CREATE INDEX ON sale_order(partner_id, order_date). Column order critical: (partner_id, order_date) filters both efficiently, (order_date, partner_id) only filters order_date. (3) Covering: CREATE INDEX ON res_partner(country_id) INCLUDE (name, email) = index-only scan, no table access, 2-3x faster. (4) Partial: CREATE INDEX ON sale_order(partner_id) WHERE state IN ('draft','sent','sale') = 50% smaller index. Analysis: EXPLAIN ANALYZE shows execution time (45s → 2.1ms = 21,000x faster). Seq Scan = missing index. Index Scan = using index. Monitoring: pg_stat_user_indexes WHERE idx_scan = 0 finds unused indexes (remove to save space). Rule: Index columns you filter, sort, or join on. Don't index everything (write cost). Impact: Proper indexing = lightning-fast queries, excellent UX. No indexing = timeout errors, $100k-$250k in lost productivity.
The Indexing Performance Gap
Your D2C order report shows 50,000 orders. User filters by customer and date range:
WHERE partner_id = 123 AND order_date >= '2025-01-01'
Without Index
- ✗ Database scans all 50,000 rows sequentially
- ✗ Takes 5 seconds
- ✗ CPU at 100%
With Composite Index on (partner_id, order_date)
- ✓ Database jumps directly to customer 123's orders
- ✓ Returns results instantly (< 0.1 second)
- ✓ CPU at 1%
5 seconds → 0.1 seconds
That's 50x faster. Zero code changes.
We've implemented 150+ Odoo systems. The ones with proper indexing? Lightning-fast queries, excellent user experience, database CPU relaxed. The ones without? Constant performance complaints, timeout errors, staff workarounds, eventual system replacement. That's $100,000-$250,000 in lost productivity and platform switching.
Understanding Indexes
What it is: A sorted data structure allowing fast lookups instead of scanning every row.
Analogy: Book without index = read every page to find topic. Book with index = look up topic, jump to page. Same as database indexes.
Cost/Benefit
| Benefit | Cost |
|---|---|
| ✓ Fast reads (10-1000x faster) | ✗ Slower writes (must update index) |
| ✓ Low CPU usage on queries | ✗ Uses disk space (10-30% of table) |
| ✓ Better user experience | ✗ Must be maintained (VACUUM ANALYZE) |
Rule: Index columns you filter, sort, or join on. Don't index everything.
Index Types
Type 1: Single Column Index (Most Common)
# In Odoo model
class SaleOrder(models.Model):
_inherit = 'sale.order'
# Frequently filtered
partner_id = fields.Many2one('res.partner', index=True)
# Frequently sorted
order_date = fields.Date(index=True)
# Frequently filtered
state = fields.Selection(index=True)
# NOT filtered or sorted → no index needed
description = fields.Text()
notes = fields.Html()
CREATE INDEX idx_sale_order_partner_id ON sale_order(partner_id);
CREATE INDEX idx_sale_order_date ON sale_order(order_date);
CREATE INDEX idx_sale_order_state ON sale_order(state);
Type 2: Composite Index (Multiple Columns)
When to use: Query filters on 2+ columns.
-- Query: WHERE partner_id = 123 AND order_date >= '2025-01-01'
-- Index: (partner_id, order_date)
CREATE INDEX idx_sale_order_partner_date
ON sale_order(partner_id, order_date);
-- Column order CRITICAL:
-- Good: (partner_id, order_date) - filters both columns
-- Bad: (order_date, partner_id) - only filters order_date efficiently
Why Order Matters
Partner1 → [order1, order2, order3, ...]
Partner2 → [order4, order5, order6, ...]
Partner3 → [order7, order8, order9, ...]
Query: partner_id = 2 AND order_date >= '2025-01-01'
- - Jump to Partner2 (using first column)
- - Find dates >= 2025-01-01 (using second column)
- - FAST!
Type 3: Covering Index (All Columns in Index)
-- Query: SELECT name, email FROM res_partner WHERE country_id = 1
-- Index includes all needed columns → "index-only scan"
CREATE INDEX idx_partner_country_covering
ON res_partner(country_id) INCLUDE (name, email);
-- Database reads index only, not main table
-- 2-3x faster
Type 4: Partial Index (Filtered Rows)
-- Only index active records (saves 50% of index size)
CREATE INDEX idx_sale_order_active
ON sale_order(partner_id)
WHERE state IN ('draft', 'sent', 'sale');
-- Queries: WHERE state IN ('draft', 'sent', 'sale') AND partner_id = 123
-- Uses partial index (faster, smaller)
-- Queries: WHERE state = 'cancel'
-- Doesn't use index (only 1% of data anyway)
Real D2C Example: Complete Indexing Strategy
Scenario: E-commerce with 10 million orders, 100,000 products, 50,000 customers.
Step 1: Identify Slow Queries
-- Enable logging of slow queries
ALTER SYSTEM SET log_min_duration_statement = 1000; -- Log queries > 1 second
SELECT pg_reload_conf();
-- Check logs for slow queries
-- Find top 10 slowest
Step 2: Analyze Slowest Query
-- Query: Users filter orders by customer and date range
EXPLAIN ANALYZE
SELECT * FROM sale_order
WHERE partner_id = 123
AND order_date >= '2025-01-01'
AND order_date <= '2025-12-31';
-- Output before index:
-- Seq Scan on sale_order (cost=0.00..500000.00 rows=10000000 width=200)
-- Filter: (partner_id = 123 AND order_date >= '2025-01-01' AND order_date <= '2025-12-31')
-- Actual Rows: 50
-- Planning Time: 0.2ms
-- Execution Time: 45000.0ms ← 45 SECONDS!
-- Create composite index
CREATE INDEX idx_sale_order_partner_date
ON sale_order(partner_id, order_date);
-- Re-run EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT * FROM sale_order
WHERE partner_id = 123
AND order_date >= '2025-01-01'
AND order_date <= '2025-12-31';
-- Output after index:
-- Index Scan using idx_sale_order_partner_date on sale_order (cost=0.42..1234.56 rows=50 width=200)
-- Index Cond: (partner_id = 123 AND order_date >= '2025-01-01' AND order_date <= '2025-12-31')
-- Actual Rows: 50
-- Planning Time: 0.1ms
-- Execution Time: 2.1ms ← 2 MILLISECONDS! 21,000x FASTER!
Step 3: Create All Necessary Indexes
-- Sale Orders
CREATE INDEX idx_sale_order_partner ON sale_order(partner_id);
CREATE INDEX idx_sale_order_state ON sale_order(state);
CREATE INDEX idx_sale_order_date ON sale_order(order_date DESC);
CREATE INDEX idx_sale_order_partner_state_date
ON sale_order(partner_id, state, order_date DESC);
-- Sale Order Lines
CREATE INDEX idx_sale_order_line_order ON sale_order_line(order_id);
CREATE INDEX idx_sale_order_line_product ON sale_order_line(product_id);
-- Products
CREATE INDEX idx_product_categ ON product_product(categ_id);
CREATE INDEX idx_product_code ON product_product(default_code);
-- Customers
CREATE INDEX idx_partner_country ON res_partner(country_id);
CREATE INDEX idx_partner_email ON res_partner(email);
-- Invoices
CREATE INDEX idx_account_move_partner ON account_move(partner_id);
CREATE INDEX idx_account_move_state ON account_move(state);
-- Stock
CREATE INDEX idx_stock_move_product ON stock_move(product_id);
CREATE INDEX idx_stock_move_state ON stock_move(state);
Indexing Many2one Fields (Critical!)
class SaleOrder(models.Model):
_inherit = 'sale.order'
# This creates index on partner_id column (critical!)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
index=True, # ← Creates database index
required=True
)
# Relationship fields ALWAYS benefit from indexing
user_id = fields.Many2one(
'res.users',
string='Salesman',
index=True
)
# Company field (filtered in multi-company setups)
company_id = fields.Many2one(
'res.company',
index=True
)
Result: All queries filtering by these fields become instant.
Monitoring Index Usage
Find Unused Indexes (Wasting Space)
-- Indexes with 0 scans = not being used
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexname NOT LIKE 'pg_toast%'
ORDER BY pg_relation_size(indexrelid) DESC;
-- Remove unused indexes (saves space, speeds writes)
-- DROP INDEX index_name;
Find Heavily-Used Indexes (Working Hard)
-- Indexes with high scan counts = being used effectively
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read,
ROUND(100 * idx_tup_read / NULLIF(idx_scan, 0), 2) as avg_rows_returned
FROM pg_stat_user_indexes
WHERE idx_scan > 0
ORDER BY idx_scan DESC
LIMIT 20;
Find Missing Indexes (Full Table Scans)
-- Queries doing full table scans (slow)
SELECT query, seq_scan, seq_tup_read, idx_scan
FROM pg_stat_statements
WHERE seq_scan > 100
AND query NOT LIKE '%pg_stat%'
ORDER BY seq_tup_read DESC
LIMIT 10;
-- These queries need indexes!
Action Items: Implement Indexing
Immediate (Identify)
❏ Find 5 slowest queries in your system
❏ Run EXPLAIN ANALYZE on each
❏ Check for "Seq Scan" (indicates missing index)
Short-term (Implement)
❏ Create indexes on frequently-filtered fields
❏ Create composite indexes for multi-column filters
❏ Run VACUUM ANALYZE after creating indexes
Ongoing (Monitor)
❏ Weekly check of unused indexes (remove them)
❏ Monthly check of slow queries (add indexes)
❏ Monitor index growth (remove duplicates)
Frequently Asked Questions
How do I create indexes in Odoo models?
Add index=True parameter to field definitions. Syntax: partner_id = fields.Many2one('res.partner', index=True). Field types to index: Many2one (relationship fields, always benefit), Selection (status/state fields filtered frequently), Date/Datetime (filtered by date ranges), Char (if used in searches, e.g., reference numbers). What Odoo does: Generates CREATE INDEX statement when module installed/upgraded. Index name format: model_name_field_name_index. Example: partner_id on sale.order creates index sale_order_partner_id_index. SQL equivalent: CREATE INDEX idx_sale_order_partner ON sale_order(partner_id). When NOT to index: Text/Html fields (large content), fields never filtered/sorted, computed fields without store=True. Verification: Check PostgreSQL with \d+ sale_order to see all indexes. Best practice: Index all Many2one fields, state/status Selection fields, date fields used in reports.
What is a composite index and when should I use it?
Composite index covers multiple columns, used when queries filter on 2+ fields together. Syntax: CREATE INDEX idx_name ON table(column1, column2, column3). When to use: Query filters: WHERE partner_id = 123 AND order_date >= '2025-01-01' = create composite index (partner_id, order_date). Query sorts: ORDER BY partner_id, order_date = composite index enables efficient sort. Column order critical: Put most selective column first (fewest unique values). Example: (partner_id, order_date) good if filtering both. (order_date, partner_id) only uses order_date efficiently. Rule: Index columns in same order as WHERE clause. Example: 50,000 orders filtered by customer+date = 45s without index, 2.1ms with composite = 21,000x faster. Odoo limitation: Can't create composite indexes in Python models, must use SQL migration script. Trade-off: Composite indexes are larger but eliminate need for multiple single-column indexes.
How do I find slow queries that need indexes?
Use EXPLAIN ANALYZE and PostgreSQL slow query logging. Step 1 - Enable logging: ALTER SYSTEM SET log_min_duration_statement = 1000; (logs queries >1s), SELECT pg_reload_conf();, check /var/log/postgresql/ for slow queries. Step 2 - Analyze query: EXPLAIN ANALYZE SELECT * FROM sale_order WHERE partner_id = 123;. Look for "Seq Scan" (bad, sequential scan = missing index) vs "Index Scan" (good, using index). Check "Execution Time" (should be <100ms for most queries). Step 3 - Find patterns: pg_stat_statements view shows query statistics (requires pg_stat_statements extension). Look for seq_scan > 100, high seq_tup_read values. Step 4 - Create index: CREATE INDEX ON table(filtered_column), re-run EXPLAIN ANALYZE, verify "Index Scan" used, verify execution time reduced. Monitoring: pg_stat_user_indexes shows idx_scan (times index used), idx_scan = 0 means unused index (remove it).
Should I index every field in my Odoo models?
No. Only index fields you filter, sort, or join on. Over-indexing slows writes. Index these: Many2one relationship fields (partner_id, user_id, company_id), Selection fields for status (state, stage_id), Date/Datetime fields (order_date, create_date), Char fields used in searches (reference, default_code). Don't index these: Text/Html fields (too large), fields never filtered (description, notes), highly unique fields with no filters (auto-generated UUIDs unless searched), computed fields without store=True (recomputed anyway). Write cost: Each index adds 5-10% overhead on INSERT/UPDATE. 10 indexes on table = 50-100% slower writes. Space cost: Indexes use 10-30% of table size. 1GB table with 5 indexes = 1.5GB total. Sweet spot: 3-7 indexes per table for Odoo models. Monitoring: Check unused indexes monthly (idx_scan = 0), remove if not used in 3+ months. Rule: Create index when query is slow, not preemptively.
Free Database Indexing Audit
Stop accepting slow queries. We'll identify your slowest queries, analyze with EXPLAIN ANALYZE, design optimal indexes, implement and measure improvements, and train your team on indexing. Most D2C brands can achieve 10-100x query speedups with proper indexing. Cost: 4-6 hours consulting. Value: $80,000-$200,000 in infrastructure you don't need to buy.
