| name | database-helper |
| description | Assists with database schema design, query optimization, migrations, and data modeling. Use when designing database schemas, writing complex SQL queries, optimizing slow queries, creating migrations, choosing appropriate indexes, or working with ORMs and database-specific features. |
Database Helper Skill
This skill helps with all aspects of database work including schema design, query writing, optimization, and migrations. Use this whenever you need to design data models, write complex queries, or improve database performance.
Database Design Principles
1. Normalization Levels
| Normal Form | Rule | Purpose |
|---|
| 1NF | Atomic values only | No repeating groups |
| 2NF | 1NF + No partial dependencies | Full functional dependency |
| 3NF | 2NF + No transitive dependencies | Only key dependencies |
| BCNF | Every determinant is a candidate key | Stricter 3NF |
2. When to Denormalize
- Read-heavy workloads
- Complex aggregations needed frequently
- Performance > storage cost
- Reporting/analytics use cases
3. Data Types Selection
| Data Type | PostgreSQL | MySQL | Use Case |
|---|
| Primary Key | BIGSERIAL / UUID | BIGINT AUTO_INCREMENT | Identity |
| String (short) | VARCHAR(255) | VARCHAR(255) | Names, emails |
| String (long) | TEXT | TEXT / LONGTEXT | Content |
| Integer | INTEGER / BIGINT | INT / BIGINT | Counts |
| Decimal | NUMERIC(10,2) | DECIMAL(10,2) | Money |
| Boolean | BOOLEAN | TINYINT(1) | Flags |
| Date/Time | TIMESTAMPTZ | DATETIME / TIMESTAMP | Events |
| JSON | JSONB | JSON | Flexible data |
| Enum | CREATE TYPE | ENUM(...) | Fixed options |
Schema Design Patterns
Basic Entity Table
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
uuid UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) DEFAULT 'pending' NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMPTZ,
CONSTRAINT users_email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
CONSTRAINT users_status_valid CHECK (status IN ('pending', 'active', 'inactive', 'banned'))
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_status ON users(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_created_at ON users(created_at DESC);
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
One-to-Many Relationship
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
order_number VARCHAR(50) UNIQUE NOT NULL,
total_amount NUMERIC(12,2) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id)
REFERENCES customers(id)
ON DELETE RESTRICT
ON UPDATE CASCADE
);
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
Many-to-Many Relationship
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
parent_id BIGINT REFERENCES categories(id),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE product_categories (
product_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
is_primary BOOLEAN DEFAULT FALSE,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (product_id, category_id),
CONSTRAINT fk_pc_product
FOREIGN KEY (product_id)
REFERENCES products(id)
ON DELETE CASCADE,
CONSTRAINT fk_pc_category
FOREIGN KEY (category_id)
REFERENCES categories(id)
ON DELETE CASCADE
);
CREATE INDEX idx_pc_product_id ON product_categories(product_id);
CREATE INDEX idx_pc_category_id ON product_categories(category_id);
Polymorphic Associations
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id),
commentable_type VARCHAR(50) NOT NULL,
commentable_id BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT comments_type_valid
CHECK (commentable_type IN ('post', 'product', 'article'))
);
CREATE INDEX idx_comments_polymorphic
ON comments(commentable_type, commentable_id);
Audit/History Table
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
balance NUMERIC(15,2) DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE account_history (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
field_name VARCHAR(50) NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by BIGINT REFERENCES users(id),
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
change_reason TEXT,
ip_address INET,
user_agent TEXT
);
CREATE INDEX idx_account_history_account ON account_history(account_id);
CREATE INDEX idx_account_history_changed_at ON account_history(changed_at DESC);
CREATE OR REPLACE FUNCTION audit_account_changes()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.balance IS DISTINCT FROM NEW.balance THEN
INSERT INTO account_history (account_id, field_name, old_value, new_value)
VALUES (NEW.id, 'balance', OLD.balance::TEXT, NEW.balance::TEXT);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_audit_accounts
AFTER UPDATE ON accounts
FOR EACH ROW
EXECUTE FUNCTION audit_account_changes();
Query Patterns
Pagination
SELECT * FROM products
ORDER BY created_at DESC
LIMIT 20 OFFSET 100;
SELECT * FROM products
WHERE created_at < '2025-01-15T10:30:00Z'
OR (created_at = '2025-01-15T10:30:00Z' AND id < 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;
SELECT
*,
COUNT(*) OVER() as total_count
FROM products
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
Full-Text Search
ALTER TABLE products ADD COLUMN search_vector tsvector;
UPDATE products SET
search_vector = to_tsvector('english',
coalesce(name, '') || ' ' ||
coalesce(description, '')
);
CREATE INDEX idx_products_search ON products USING GIN(search_vector);
SELECT *, ts_rank(search_vector, query) as rank
FROM products, to_tsquery('english', 'laptop & gaming') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20;
CREATE FUNCTION products_search_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('english',
coalesce(NEW.name, '') || ' ' ||
coalesce(NEW.description, '')
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_products_search
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION products_search_trigger();
Hierarchical Data (Recursive CTE)
WITH RECURSIVE category_tree AS (
SELECT id, name, parent_id, 0 as level, ARRAY[id] as path
FROM categories
WHERE id = 1
UNION ALL
SELECT c.id, c.name, c.parent_id, ct.level + 1, ct.path || c.id
FROM categories c
INNER JOIN category_tree ct ON c.parent_id = ct.id
WHERE NOT c.id = ANY(ct.path)
)
SELECT * FROM category_tree
ORDER BY path;
WITH RECURSIVE category_ancestors AS (
SELECT id, name, parent_id, 0 as level
FROM categories
WHERE id = 15
UNION ALL
SELECT c.id, c.name, c.parent_id, ca.level + 1
FROM categories c
INNER JOIN category_ancestors ca ON c.id = ca.parent_id
)
SELECT * FROM category_ancestors
ORDER BY level DESC;
Aggregate with Grouping
SELECT
COALESCE(region, 'TOTAL') as region,
COALESCE(product_category, 'ALL CATEGORIES') as category,
DATE_TRUNC('month', order_date) as month,
COUNT(*) as order_count,
SUM(amount) as total_sales,
AVG(amount) as avg_order_value
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE order_date >= '2024-01-01'
GROUP BY ROLLUP(region, product_category), DATE_TRUNC('month', order_date)
ORDER BY region NULLS LAST, category NULLS LAST, month;
SELECT
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date) as running_total,
AVG(amount) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as seven_day_avg,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) as rn
FROM orders;
UPSERT (Insert or Update)
INSERT INTO user_preferences (user_id, preference_key, preference_value)
VALUES (123, 'theme', 'dark')
ON CONFLICT (user_id, preference_key)
DO UPDATE SET
preference_value = EXCLUDED.preference_value,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO user_preferences (user_id, preference_key, preference_value)
VALUES (123, 'theme', 'dark')
ON DUPLICATE KEY UPDATE
preference_value = VALUES(preference_value),
updated_at = CURRENT_TIMESTAMP;
Index Strategies
Index Types
CREATE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);
CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at DESC);
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';
CREATE INDEX idx_products_category_covering
ON products(category_id)
INCLUDE (name, price);
CREATE INDEX idx_products_tags ON products USING GIN(tags);
CREATE INDEX idx_users_metadata ON users USING GIN(metadata jsonb_path_ops);
CREATE INDEX idx_locations_coords ON locations USING GIST(coordinates);
CREATE INDEX idx_logs_timestamp ON logs USING BRIN(created_at);
Index Selection Guidelines
| Query Pattern | Index Type | Example |
|---|
Equality (=) | B-Tree | WHERE email = 'x' |
Range (<, >, BETWEEN) | B-Tree | WHERE created_at > '2024-01-01' |
Pattern (LIKE 'abc%') | B-Tree | WHERE name LIKE 'John%' |
Pattern (LIKE '%abc%') | GIN + pg_trgm | Full-text search |
| Array contains | GIN | WHERE tags @> ARRAY['tag1'] |
| JSONB queries | GIN | WHERE data @> '{"key": "value"}' |
| Geometry | GiST | WHERE ST_Contains(area, point) |
| Time-series data | BRIN | Large append-only tables |
When NOT to Index
- Small tables (< 1000 rows)
- Columns with low cardinality (e.g., boolean, status)
- Frequently updated columns
- Columns rarely used in WHERE/JOIN/ORDER BY
- Wide indexes on write-heavy tables
Query Optimization
EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.*, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.status = 'active'
GROUP BY u.id
ORDER BY order_count DESC
LIMIT 10;
Common Optimization Patterns
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
ALTER TABLE users ALTER COLUMN email TYPE CITEXT;
SELECT * FROM products WHERE category_id = 1 OR category_id = 2;
SELECT * FROM products WHERE category_id IN (1, 2);
SELECT * FROM users WHERE id = 123;
SELECT id, name, email FROM users WHERE id = 123;
SELECT
u.*,
(SELECT COUNT(*) FROM orders WHERE user_id = u.id) as order_count
FROM users u;
SELECT u.*, COALESCE(o.order_count, 0) as order_count
FROM users u
LEFT JOIN (
SELECT user_id, COUNT(*) as order_count
FROM orders
GROUP BY user_id
) o ON o.user_id = u.id;
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM banned_users);
SELECT u.* FROM users u
LEFT JOIN banned_users b ON b.user_id = u.id
WHERE b.user_id IS NULL;
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
SELECT * FROM products
WHERE created_at < '2024-01-15T10:30:00Z'
ORDER BY created_at DESC
LIMIT 20;
N+1 Query Prevention
users = User.query.all()
for user in users:
orders = Order.query.filter_by(user_id=user.id).all()
print(f"{user.name}: {len(orders)} orders")
users = User.query.options(joinedload(User.orders)).all()
for user in users:
print(f"{user.name}: {len(user.orders)} orders")
users = User.query.options(subqueryload(User.orders)).all()
results = db.session.query(
User,
func.count(Order.id).label('order_count')
).outerjoin(Order).group_by(User.id).all()
Migrations
Migration Structure
"""Add user preferences table
Revision ID: a1b2c3d4e5f6
Revises: previous_revision_id
Create Date: 2025-01-15 10:30:00
"""
from alembic import op
import sqlalchemy as sa
revision = 'a1b2c3d4e5f6'
down_revision = 'previous_revision_id'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'user_preferences',
sa.Column('id', sa.BigInteger(), primary_key=True),
sa.Column('user_id', sa.BigInteger(), nullable=False),
sa.Column('preference_key', sa.String(100), nullable=False),
sa.Column('preference_value', sa.Text()),
sa.Column('created_at', sa.DateTime(timezone=True),
server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True),
server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'],
ondelete='CASCADE'),
sa.UniqueConstraint('user_id', 'preference_key',
name='uq_user_preferences')
)
op.create_index('idx_user_preferences_user_id',
'user_preferences', ['user_id'])
def downgrade():
op.drop_index('idx_user_preferences_user_id')
op.drop_table('user_preferences')
Safe Migration Patterns
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN is_verified BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
UPDATE users SET phone = '' WHERE phone IS NULL;
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
CREATE INDEX CONCURRENTLY idx_users_phone ON users(phone);
ALTER TABLE users DROP COLUMN deprecated_field;
Zero-Downtime Migration Pattern
ALTER TABLE users ADD COLUMN new_field VARCHAR(255);
UPDATE users SET new_field = old_field WHERE new_field IS NULL;
ALTER TABLE users ALTER COLUMN new_field SET NOT NULL;
ALTER TABLE users DROP COLUMN old_field;
Connection Pooling
from sqlalchemy import create_engine
engine = create_engine(
"postgresql://user:pass@localhost/db",
pool_size=10,
max_overflow=20,
pool_timeout=30,
pool_recycle=1800,
pool_pre_ping=True,
)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydb',
'CONN_MAX_AGE': 600,
'CONN_HEALTH_CHECKS': True,
}
}
Database Checklist
Schema Design
Performance
Migrations
Output Format
When helping with database tasks, provide:
## Database Solution
### Schema Design
```sql
[SQL DDL statements]
Query
[Optimized query]
Indexes Recommended
[Index recommendations with rationale]
Migration Steps
- [Step 1]
- [Step 2]
Performance Notes
- [Consideration 1]
- [Consideration 2]
## Notes
- Always test queries with realistic data volumes
- Use EXPLAIN ANALYZE before and after optimization
- Consider read/write ratio when designing indexes
- Plan for data growth and scalability
- Backup before running migrations
- Monitor query performance in production