Rails db best practices
Skill Tyr0/agent-skills/plugins/rails-expert/skills/rails-db-best-practices
A collection of skills, plugins, and agents for AI workflows.
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.
What its author says it does
Copied from the file, not written here
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.
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.