Rails db best practices
Skill Tyr0/agent-skills/plugins/rails-expert/skills/rails-db-best-practices
Use this skill whenever the user asks about Rails or Postgres database schema design, indexing strategies, association best practices, join tables, counter caches, or database anti-patterns. Also use it for questions about Postgres-specific features like JSONB, partial indexes, GIN/GiST indexes, generated columns, advisory locks, full-text search, or upsert. Triggers on code reviews of migrations or models where schema quality is a concern, or when a user asks 'is this schema efficient' or 'when should I add an index'. For N+1 queries, eager loading, and ActiveRecord query optimization use the rails-activerecord-queries skill instead.From its SKILL.md
npx -y skills add Tyr0/agent-skills --skill rails-db-best-practicesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
16.2 KB, ~3.9k tokens by cl100k_base, as published. Nobody here has run it
Rails & Postgres Database Best Practices
A dense reference for schema design, indexing, ActiveRecord associations, and Postgres-specific capabilities.
Indexing
Do's
-
Index every foreign key. Rails does not add these automatically. A missing FK index causes full table scans on every
JOINandWHEREon the FK column.# Good migration add_reference :posts, :user, null: false, foreign_key: true, index: true -
Use
algorithm: :concurrentlyon Postgres for zero-downtime index creation. StandardCREATE INDEXholds a write lock; concurrent does not.class AddIndexOnOrdersStatus < ActiveRecord::Migration[7.2] disable_ddl_transaction! def change add_index :orders, :status, algorithm: :concurrently end end -
Prefer composite indexes when queries always filter on multiple columns together. Column order matters — put the highest-cardinality or equality-filtered column first.
# Covers WHERE user_id = ? AND status = ? add_index :orders, [:user_id, :status] -
Use partial indexes to keep indexes small and targeted.
-- Only index active users CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;Rails equivalent:
add_index :users, :email, where: "deleted_at IS NULL" -
Add
unique: trueat the database level, not just with ActiveRecord validations. Validations have a TOCTOU race condition under concurrent requests.add_index :users, :email, unique: true -
Cover frequently queried columns together to enable index-only scans. If a query only touches columns
aandb, a composite index(a, b)can satisfy it without hitting the heap.
Don'ts
-
Don't index low-cardinality columns in isolation (e.g., a boolean
activeon a large table). The planner will often prefer a seq scan; the index wastes space and slows writes. -
Don't index every column "just in case." Each index adds write overhead on
INSERT,UPDATE, andDELETE. Only add indexes backed by real query patterns. -
Don't forget to drop unused indexes. Use
pg_stat_user_indexesto find indexes with zero scans:SELECT relname, indexrelname, idx_scan FROM pg_stat_user_indexes WHERE schemaname = 'public' ORDER BY idx_scan ASC; -
Don't use
add_indexwithoutalgorithm: :concurrentlyon production tables. The default acquires a full table lock. -
Don't rely on a leading composite index for single-column lookups on a non-leading column. An index on
(user_id, created_at)cannot efficiently satisfyWHERE created_at = ?alone.
Associations
Do's
-
Declare
belongs_towithoptional: false(the Rails 5+ default) to enforce presence at the model level. Pair it with a database-levelNOT NULLconstraint and a foreign key.belongs_to :user # optional: false by default in Rails 5+ -
Use
has_many :throughfor many-to-many with extra attributes on the join record. Usehas_and_belongs_to_manyonly when the join is a pure pivot with no extra data (rare in practice).# Preferred class Enrollment < ApplicationRecord belongs_to :student belongs_to :course # extra columns: enrolled_at, grade end class Student < ApplicationRecord has_many :enrollments has_many :courses, through: :enrollments end -
Use
dependent:deliberately. Choose based on the ownership semantics:dependent: :destroy— load each record and call itsdestroycallbacks (safe but slow for large sets)dependent: :delete_all— singleDELETESQL, skips callbacks (fast, use when no callbacks needed)dependent: :nullify— sets FK to NULL, for optional ownershipdependent: :restrict_with_error— prevent parent deletion when children exist
-
Add
inverse_ofwhen Rails can't infer it (non-standard naming,:throughassociations). Prevents unnecessary extra queries and ensures in-memory object identity.has_many :authored_posts, class_name: "Post", foreign_key: :author_id, inverse_of: :author belongs_to :author, class_name: "User", inverse_of: :authored_posts -
Use
counter_cachefor frequently displayed child counts. Avoids aCOUNT(*)query on every render.class Comment < ApplicationRecord belongs_to :post, counter_cache: true end # Requires a `comments_count` integer column on posts with default: 0Migration:
add_column :posts, :comments_count, :integer, null: false, default: 0 -
Use polymorphic associations sparingly. They break referential integrity (no real FK is possible) and complicate indexing. Prefer STI or separate join tables when the relationship set is bounded.
Don'ts
-
Don't use
has_and_belongs_to_manywhen you may need to add columns to the join table later. Migrating tohas_many :throughis painful. Default tohas_many :throughwith an explicit join model. -
Don't call
.counton an association when a counter cache exists..sizeuses the cache;.countalways hits the database.post.comments.size # uses counter_cache if present post.comments.count # always fires a COUNT query -
Don't omit
null: falseon foreign key columns. A nullable FK means a record that references nothing — almost always a data quality bug.
Join Tables
Do's
-
Name join tables in alphabetical order by convention when using
has_and_belongs_to_many(e.g.,assemblies_parts, notparts_assemblies). Forhas_many :through, name the join model semantically (e.g.,enrollments, notcourses_students). -
Always add a composite unique index on join tables to prevent duplicate pivot rows.
add_index :enrollments, [:student_id, :course_id], unique: true -
Add individual indexes on each FK in the join table for efficient reverse lookups.
add_index :enrollments, :course_id # student_id is covered by the composite unique index -
Use
insert_all/upsert_allfor bulk join record creation to avoid N individual inserts.Enrollment.insert_all( students.map { |s| { student_id: s.id, course_id: course.id, enrolled_at: Time.current } } )
Don'ts
-
Don't omit a primary key from join tables without a reason. Rails assumes a primary key; its absence breaks many ActiveRecord helpers. Only omit it if you're using the composite FK pair as a natural PK and you know the implications.
-
Don't allow duplicate rows in a join table without an explicit reason. Missing the unique index will cause phantom duplication bugs when callbacks fire multiple times.
Schema Design Best Practices
Do's
-
Add
null: falseconstraints to columns that must always have a value. Database-level constraints are authoritative; model validations are a convenience layer. -
Use
timestampson every table (created_at,updated_at). The cost is two columns; the benefit is an audit trail and trivial cache key generation. -
Prefer
stringwith alimitortextintentionally.stringmaps tovarchar(255)by default;textis unbounded. Usetextwhen you don't want a length limit; usestringwithlimit:when you want the database to enforce it. -
Use
bigint(the Rails default) for primary keys. If you anticipate > 2B rows, move touuidfrom the start. -
Use UUIDs for primary keys when records are created across multiple systems or when exposing IDs externally (prevents enumeration attacks).
create_table :users, id: :uuid, default: "gen_random_uuid()" do |t| t.string :email, null: false t.timestamps end -
Use
checkconstraints to enforce domain invariants at the database level.add_check_constraint :orders, "amount > 0", name: "orders_amount_positive" -
Normalize aggressively first; denormalize deliberately for performance. Premature denormalization adds inconsistency risk without a proven query bottleneck.
Don'ts
-
Don't store arrays or hashes in
textcolumns as serialized strings. Use JSONB (Postgres) or a proper join table instead. -
Don't store money as
float. Floating-point precision errors will corrupt financial data. Usedecimalwith explicitprecisionandscale, or an integer number of cents.# Bad t.float :price # Good t.decimal :price, precision: 10, scale: 2 # Or: store as integer cents t.integer :price_cents, null: false, default: 0 -
Don't store time zones as offsets. Store timestamps in UTC (
timestamptzin Postgres) and convert in the application layer. -
Don't add
default: nilexplicitly. It is the implicit default; writing it adds noise with no benefit. -
Don't use EAV (Entity-Attribute-Value) tables as a schema escape hatch. They destroy query performance and type safety. Use JSONB or STI instead.
Postgres-Specific Features
Postgres offers capabilities that generic ActiveRecord abstractions do not expose. Use them deliberately.
JSONB
Store semi-structured data with full indexability. Unlike json, jsonb is stored as a binary tree and supports GIN indexing.
# Migration
add_column :products, :metadata, :jsonb, null: false, default: {}
add_index :products, :metadata, using: :gin
# Query
Product.where("metadata @> ?", { color: "red" }.to_json)
Do: Use JSONB for truly variable or sparse attributes (e.g., per-product custom fields).
Don't: Use JSONB as a substitute for normalized columns on data you need to reliably GROUP BY, JOIN, or aggregate. Postgres can query JSONB but the ergonomics are worse than structured columns.
Array Columns
add_column :posts, :tags, :string, array: true, default: []
add_index :posts, :tags, using: :gin
Post.where("? = ANY(tags)", "rails")
Do: Use for small, bounded, unordered sets of scalars that are always queried together with the parent row. Don't: Use arrays as a substitute for a proper association when you need to query individual elements frequently or maintain referential integrity.
Partial Indexes
Already covered in the Indexing section. Partial indexes are a first-class Postgres feature — use them aggressively for filtered queries (e.g., WHERE status = 'active', WHERE deleted_at IS NULL).
GIN and GiST Indexes
| Type | Best for |
|---|---|
| GIN | JSONB containment (@>), array overlap (&&), full-text search (tsvector) |
| GiST | Geometric/range types, full-text search, nearest-neighbor |
# Full-text search with GIN
add_column :articles, :search_vector, :tsvector
add_index :articles, :search_vector, using: :gin
# Keep it updated with a trigger (or in an AR callback)
execute <<~SQL
CREATE OR REPLACE FUNCTION articles_search_vector_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
to_tsvector('english', coalesce(NEW.title, '') || ' ' || coalesce(NEW.body, ''));
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE TRIGGER articles_search_vector_update
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION articles_search_vector_update();
SQL
Generated Columns
Postgres 12+ supports stored generated columns — computed from other columns, persisted, and indexable.
ALTER TABLE users
ADD COLUMN full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;
Rails 7.1+ exposes this:
t.virtual :full_name, type: :string, as: "first_name || ' ' || last_name", stored: true
Do: Use for computed search or display fields that are expensive to recalculate and need to be indexed.
Upsert
# Rails 6+
User.upsert_all(
[{ email: "[email protected]", name: "Alice" }, { email: "[email protected]", name: "Bob" }],
unique_by: :email,
update_only: [:name]
)
Postgres-native: INSERT ... ON CONFLICT DO UPDATE.
Do: Use for idempotent sync jobs, data imports, and background cache refreshes. Don't: Use upsert as a substitute for proper business logic that must distinguish insert from update (e.g., when the two paths have different side effects).
Advisory Locks
Lightweight application-level locks tied to the Postgres connection — no separate lock table needed.
# Try to acquire a lock for a specific job class
def with_advisory_lock(key)
result = ActiveRecord::Base.connection.execute(
"SELECT pg_try_advisory_lock(hashtext('#{key}'))"
).first["pg_try_advisory_lock"]
return unless result == "t"
begin
yield
ensure
ActiveRecord::Base.connection.execute(
"SELECT pg_advisory_unlock(hashtext('#{key}'))"
)
end
end
Do: Use for distributed mutual exclusion (e.g., ensuring only one worker processes a cron job at a time).
Don't: Use as a general row-level lock — use SELECT ... FOR UPDATE for row locking.
Range Types
Postgres has native range types (int4range, tstzrange, daterange, etc.) with overlap operators.
-- Find all events overlapping a period
SELECT * FROM events WHERE duration && '[2024-01-01, 2024-01-31)'::daterange;
Do: Use for scheduling, billing periods, and any domain with overlap/containment semantics.
Don't: Model date ranges as starts_at + ends_at columns if you frequently need overlap queries — range types give you index-backed operators for free.
EXPLAIN ANALYZE
Always run EXPLAIN (ANALYZE, BUFFERS) on slow queries before tuning.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 42 AND status = 'pending';
Key things to look for:
Seq Scanon large tables — indicates a missing or unusable indexNested Loopwith large row estimates — may indicate a missing index on the inner relation- High
Buffers: shared hitvsreadratio — cache hit rate rows=X (actual rows=Y)mismatch — stale statistics; runANALYZE tablename
Migrations: Anti-Patterns to Avoid
| Anti-Pattern | Problem | Fix |
|---|---|---|
Adding NOT NULL column with no default in one step | Locks the table during backfill on Postgres < 11 | Add nullable, backfill, then add constraint |
remove_column without deploying code that stops using it first | Causes ActiveRecord::StatementInvalid in running instances | Two-phase deploy: code change first, then migration |
| Renaming a column in one migration | Old column gone before app is updated | Add new column, dual-write, backfill, remove old |
Running add_index without algorithm: :concurrently on production | Full table write lock | Always use concurrent index creation |
| Data migrations inside schema migrations | Couples schema and data; dangerous on rollback | Use a separate Rake task or a dedicated data migration gem |
execute raw SQL in a change migration without reversible | db:rollback will crash | Wrap in `reversible { |
Rails + Postgres: Integration Checklist
-
config.active_record.schema_format = :sql— required when using Postgres-specific features (triggers, custom types, views). Commitstructure.sql. - All foreign keys have database-level
REFERENCESconstraints (foreign_key: truein migrations). - All foreign key columns have indexes.
- All unique constraints are enforced at the database level, not just via AR validations.
-
NOT NULLon all columns that must have a value. - Money stored as
decimalor integer cents — neverfloat. - Timestamps stored in UTC;
config.time_zoneandconfig.active_record.default_timezone = :utcset inapplication.rb. - Concurrent indexes used for all production index additions.
-
EXPLAIN ANALYZErun on all new queries touching tables > 10k rows.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.