.ai/principles/distilled/database-schema.md
Prerequisite: If you haven't already, also read .ai/principles/distilled/database-fundamentals.md - it contains foundational rules that apply to all database work.
add_concurrent_partitioned_index insteadadd_concurrent_index for indexes on populated tables (never inside a transactional migration)WHERE clause) when queries always filter on a known condition and target a subset of datatmp_ and create a follow-up issue to remove them in the next milestoneANALYZE explicitly in the post-deployment migration (only for non-large tables)prepare_async_index / prepare_partitioned_async_index for very large tables to schedule index creation during low-traffic windowsname: argument for any index created with where, using, order, length, type, or opclass optionsindex_<table>_on_<column>[_and_<column>]*; keep names all lowercaseindex_name_exists? (or index_exists? with name:) to test for index existence by namenulls_not_distinct: true on unique indexes when you need to enforce full uniqueness including NULL values, instead of combining two separate indexesprepare_async_index_removal to schedule index removal asynchronously, then add a synchronous follow-up migration after verifying removal in productionCleanupUnusedIndexes MR is received, verify the index is truly unused on both GitLab.com and Self-Managed (including infrequent cron jobs) before merging; if the index must stay, add it to the keep list and close the MRbigint, even if the referenced table has an integer primary keyON DELETE clause on every foreign key (use CASCADE in 99% of cases)add_concurrent_foreign_key with validate: false when adding a FK to an existing column, then validate in a separate migrationadd_foreign_key or add_concurrent_foreign_key more than once per migration file unless source and target tables are identicaladd_concurrent_foreign_key, add_concurrent_partitioned_foreign_key, remove_foreign_key_if_exists, and remove_partitioned_foreign_key all default to reverse_lock_order: true; set reverse_lock_order: false explicitly only when the FK points from a parent table to a child tableremove_partitioned_foreign_key instead of remove_foreign_key when removing FKs from partitioned tablesdependent: :destroy or dependent: :delete on associations; let the database handle cascading deletes via FK constraintsbefore_destroy or after_destroy callbacks unless approved by database specialists; use service classes for non-database cleanup_id suffix only for columns referencing another table; use _xid for third-party platform IDs_id suffix to ignored_fk_columns_map in spec/db/schema_spec.rb only when they meet the documented criteria (cross-schema, loose FK, polymorphic, or non-reference)prepare_async_foreign_key_validation / prepare_partitioned_async_foreign_key_validation to schedule validation during low-traffic windowshas_one associations), consider using a regular id column if the table is relevant for Service Ping, as FK-as-primary-key can make batch counting less efficientNOT NULL constraints to all columns that should never be NULLNOT NULL directly in create_tableNOT VALID constraint → fix existing records → validate in next releaseadd_not_null_constraint helper (not raw ALTER TABLE) for adding constraints to existing columnsbelongs_to associations requiring presence, use optional: false instead of a separate validates :field, presence: true; note that config.active_record.belongs_to_required_by_default = false is set in GitLab, so mark required associations explicitlyNOT NULL constraint in a subsequent releasevalidate: false first, then use prepare_async_check_constraint_validation for asynchronous validationNOT NULL constraint from an individual partition; drop it from the parent table so it cascadesadd_multi_column_not_null_constraint when enforcing that a specific number of columns across a set must be non-null (e.g., exactly one of project_id or group_id must be present)text data type instead of string for all string/text columnstext columns: use limit: in create_table or add_text_limit on existing tablesNOT VALID in a post-deployment migration, fix existing records, then validate in the next releasetext columns for encrypts attributes; use :jsonb columns insteadSMALLINT (limit: 2) for all new enum columnssource_type/source_id pattern); use separate tables for each type instead*_type columns as a pattern for future polymorphic expansionself.inheritance_column = :_type_disabled) when using models in migrationsdefine_batchable_model helper in migrations instead of defining a model class when only STI disabling or EachBatch is neededEnumInheritance concern instead of storing the class name stringCHECK constraints to enforce data integrity rules beyond NOT NULLCHECK constraint with a default value that satisfies the constraint, add with validate: false in the regular migration and validate in a post-deployment migrationpk_<table>, fk_<table>_<col>_<foreign_table>, index_<table>_on_<col>, unique_<table>_<col>, check_<table>_<col>[_<suffix>], excl_<table>_<col>[_<suffix>]_and_ joiners if neededdb/structure.sql for naming conflicts before adding new constraintsFor the full picture, see: