What database Rails uses
The phrase people type is "what database does Rails use", and the honest answer is that Rails does
not have one. There are four Rails database adapters, rails new picks a default, and everything
after that is a line in config/database.yml and a gem in your Gemfile. What follows is that
default printed rather than described, and then the three candidates measured side by side on one
laptop, because the reason to prefer one of them is not the reason most pages give.
All of this ran today on an Apple M2 Max, 12 cores, macOS 26.5.1. The apps were generated by the
rails 8.1.3.1 executable on PATH; rails new itself resolved rails (8.1.4) into every
Gemfile.lock, which is the version all the runtime numbers below come from. The generator files I
quote (database.rb, ci.yml.tt) are byte-identical in railties 8.1.3.1 and 8.1.4, checked with
diff. PostgreSQL is 17.7 (Homebrew) on port 15432, MySQL is 26.7.0 (Homebrew) on the socket at
/tmp/mysql.sock, sqlite3 2.9.6, pg 1.6.3, trilogy 2.13.0.
The answer, and the four lines that contain it
active_record/connection_adapters.rb in activerecord 8.1.4 registers the complete set, and there
are four of them:
register "sqlite3", "ActiveRecord::ConnectionAdapters::SQLite3Adapter", "active_record/connection_adapters/sqlite3_adapter"
register "mysql2", "ActiveRecord::ConnectionAdapters::Mysql2Adapter", "active_record/connection_adapters/mysql2_adapter"
register "trilogy", "ActiveRecord::ConnectionAdapters::TrilogyAdapter", "active_record/connection_adapters/trilogy_adapter"
register "postgresql", "ActiveRecord::ConnectionAdapters::PostgreSQLAdapter", "active_record/connection_adapters/postgresql_adapter"
That is lines 67 to 70 of the file. Oracle, SQL Server, CockroachDB and the rest exist as separate
gems that call the same register method, which is public API documented on line 18 of that file.
They are not in Rails.
Run rails new with no flags and you get SQLite. The generator says so itself:
-d, [--database=DATABASE] # Preconfigure for selected database
# Default: sqlite3
# Possible values: mysql, trilogy, postgresql, sqlite3, mariadb-mysql, mariadb-trilogy
What a default rails new dbdefault wrote into config/database.yml, production block only:
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate
Four databases, four files, because Solid Cache, Solid Queue and Solid Cable each want their own.
The default: anchor above them carries adapter: sqlite3, timeout: 5000 and
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>. That last key is new: pool is
deprecated in favour of max_connections in 8.1, and setting both to different values raises
"Ambiguous configuration: 'pool' (5) and 'max_connections' (10) are set to different values." from
database_configurations/hash_config.rb:227.
Six names for four adapters, because mariadb-mysql and mariadb-trilogy are not adapters.
Rails::Generators::Database.build("mariadb-mysql") returns a MariaDBMySQL2 whose gem is
mysql2, and the generated database.yml files are identical:
$ diff <(sed 's/dbmy/APP/g' dbmy/config/database.yml) <(sed 's/dbmaria/APP/g' dbmaria/config/database.yml)
62c62
< password: <%= ENV["DBMY_DATABASE_PASSWORD"] %>
---
> password: <%= ENV["DBMARIA_DATABASE_PASSWORD"] %>
The only difference is the environment variable named after the app. What the MariaDB flags actually
change is the Docker Compose image (mariadb:10.5, hardcoded in generators/database.rb:60) and
the CI file, and the CI part is broken, which is further down.
Nothing in database.yml is final anyway. DATABASE_URL wins over the whole file:
$ bin/rails runner 'c = ActiveRecord::Base.connection_db_config; puts "#{c.adapter} #{c.database}"'
sqlite3 storage/development.sqlite3
$ DATABASE_URL=sqlite3:storage/somewhere_else.sqlite3 bin/rails runner 'c = ActiveRecord::Base.connection_db_config; puts "#{c.adapter} #{c.database}"'
sqlite3 storage/somewhere_else.sqlite3
Pointing that URL at an adapter whose gem is not bundled fails at boot with a message that tells you exactly what to do, which is more than most such failures manage:
Error loading the 'postgresql' Active Record adapter. Missing a gem it depends on? pg is not part of the bundle. Add it to your Gemfile. (LoadError)
Rails SQLite vs PostgreSQL is not a throughput question
The received answer is that SQLite is for development and Postgres is for production because SQLite cannot take the load. Here is the load.
writers10.rb, run through bin/rails runner in each app, eight threads each inserting 250 rows
into a table of id, name, created_at, updated_at, ten times, with RAILS_MAX_THREADS=8 so the
pool matches the thread count:
THREADS = Integer(ENV.fetch("THREADS", 8))
PER = Integer(ENV.fetch("PER", 250))
RUNS = Integer(ENV.fetch("RUNS", 10))
adapter = ActiveRecord::Base.lease_connection.adapter_name
times = []
RUNS.times do
Hit.delete_all
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
THREADS.times.map do
Thread.new do
PER.times { Hit.create!(name: "x") }
ensure
ActiveRecord::Base.connection_handler.clear_active_connections!
end
end.each(&:join)
times << Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
end
s = times.sort
printf("%-11s %d runs of %d inserts, %d threads: min %.3f s median %.3f s max %.3f s\n",
adapter, RUNS, THREADS * PER, THREADS, s.first, s[RUNS / 2], s.last)
puts s.map { |x| format("%.3f", x) }.join(" ")
SQLite 10 runs of 2000 inserts, 8 threads: min 2.646 s median 2.947 s max 3.636 s
2.646 2.754 2.788 2.794 2.875 2.947 3.082 3.264 3.362 3.636
PostgreSQL 10 runs of 2000 inserts, 8 threads: min 2.951 s median 3.540 s max 8.396 s
2.951 3.015 3.036 3.378 3.418 3.540 3.946 4.476 4.593 8.396
Trilogy 10 runs of 2000 inserts, 8 threads: min 2.715 s median 3.325 s max 13.065 s
2.715 2.974 3.041 3.137 3.207 3.325 3.502 3.573 5.043 13.065
Read the conditions before the numbers, because this laptop was not quiet: uptime reported
load averages: 45.88 25.17 16.08 on 12 cores while those ran. The tails are noise from the other
work on the machine, not from the databases, which is why the medians and the minimums are the only
columns worth anything here. An earlier session of the same script on the same laptop under a
lighter load gave SQLite a median of 2.164 s, PostgreSQL 2.715 s and MySQL 2.060 s. Both sessions agree on
the only thing I am willing to claim from them: at eight concurrent writers on one box, the three
are within a small factor of each other and SQLite is not the slow one.
Durability settings matter more than the engine names here, and all three are at their defaults,
which are not equivalent. SQLite reported journal_mode = wal and synchronous = 1, both set by
SQLite3Adapter::DEFAULT_PRAGMAS rather than by anything in the app. PostgreSQL reported
synchronous_commit = on and fsync = on. MySQL reported innodb_flush_log_at_trx_commit = 1.
So SQLite is the one not paying for a durable flush per commit, and part of its lead is that. The
synchronous = normal default is a deliberate Rails choice, written into the adapter at
sqlite3_adapter.rb:114, and it is the right one under WAL, where a crash loses recent commits but
does not corrupt the file.
Single-threaded, same apps. 2000 create! calls after a 2000-row warmup that is then deleted, then
2000 find(id) calls over the ids just written:
SQLite 1 thread, 2000 ops each: create! 1.404 ms find(id) 0.461 ms
PostgreSQL 1 thread, 2000 ops each: create! 2.923 ms find(id) 0.747 ms
Trilogy 1 thread, 2000 ops each: create! 2.558 ms find(id) 0.836 ms
The gap on a primary-key read is 0.29 ms. If you are choosing a database on that, you are choosing
on something that will be invisible next to one unindexed ORDER BY or one N+1.
The failed migration is the difference
Run this exact migration on all three:
class HalfMigrationB < ActiveRecord::Migration[8.1]
def change
add_column :hits, :first_col, :string
add_column :no_such_table, :second_col, :string
end
end
The first statement is fine, the second cannot work. bin/rails db:migrate, then print the columns:
=== dbq ===
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
SQLite columns after: ["id", "name", "created_at", "updated_at"]
=== dbpg ===
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
PostgreSQL columns after: ["id", "name", "created_at", "updated_at"]
=== dbtri ===
StandardError: An error has occurred, all later migrations canceled: (StandardError)
Trilogy columns after: ["id", "name", "created_at", "updated_at", "first_col"]
MySQL kept first_col. Your schema is now in a state no migration file describes, the version was
not recorded, and re-running the fixed migration will fail on first_col already existing. SQLite
and PostgreSQL wrapped the DDL in the migration's transaction and threw it away.
Look at the error strings again. Rails says "this and all later migrations canceled" on two of them
and "all later migrations canceled" on the third, and the word it drops is "this". The branch is
one line, migration.rb:1550:
msg << "this and " if use_transaction?(migration)
and use_transaction? is keyed on the same supports_ddl_transactions? that answers false for
both MySQL adapters. The framework is telling you which of your migrations just became a manual
repair job, and it is easy to read straight past.
That flag is one row of a bigger table. This, run through bin/rails runner in each app, prints
every capability the connection will answer for:
c = ActiveRecord::Base.lease_connection
puts "ADAPTER #{c.adapter_name}"
c.methods.grep(/\Asupports_.*\?\z/).sort.each do |m|
v = begin; c.public_send(m); rescue => e; "ERR #{e.class}"; end
puts "#{m}\t#{v}"
end
43 distinct supports_*? methods appear across the three, 38 of them are defined on all three, and
22 of those 38 disagree. The ones that change what you can write:
| SQLite | PostgreSQL | MySQL | |
|---|---|---|---|
supports_ddl_transactions? |
true | true | false |
supports_insert_returning? |
true | true | false |
supports_insert_conflict_target? |
true | true | false |
supports_advisory_locks? |
false | true | true |
supports_exclusion_constraints? |
false | true | false |
supports_unique_constraints? |
false | true | false |
supports_nulls_not_distinct? |
false | true | false |
supports_materialized_views? |
false | true | false |
supports_partial_index? |
true | true | false |
supports_deferrable_constraints? |
true | true | false |
supports_extensions? |
false | true | false |
supports_insert_returning? being false is what you hit as
ArgumentError: ActiveRecord::ConnectionAdapters::TrilogyAdapter does not support :returning, from
one Hit.insert_all([...], returning: [:id]) against MySQL.
supports_advisory_locks? being false on SQLite is why two machines running
db:migrate at the same moment against one SQLite file have nothing stopping them, which is
academic for SQLite because two machines cannot share the file in the first place. PostgreSQL is the
only one of the three that answers true to supports_exclusion_constraints?,
supports_nulls_not_distinct? and supports_extensions?, and those three are most of the reason to
pick it.
Where SQLite stops
Not at throughput. At the second writer that is not in your process.
holder.rb opens a transaction, inserts, and sleeps 25 seconds. waiter.rb runs in a separate
bin/rails runner four seconds later and tries one INSERT:
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
begin
Hit.create!(name: "waiter")
puts format("waiter inserted after %.3f s", Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0)
rescue => e
puts format("waiter raised after %.3f s: %s: %s", Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0, e.class, e.message.lines.first.strip)
end
waiter raised after 5.013 s: ActiveRecord::StatementTimeout: SQLite3::BusyException: database is locked
The same two scripts against PostgreSQL:
waiter inserted after 0.071 s
Nothing PostgreSQL did there was clever. The two inserts touch different rows, so there is no
conflict to resolve. SQLite has one write lock for the whole file and the second writer queues
behind the first until timeout: 5000 runs out. Any request handler that holds a transaction open
across something slow, an HTTP call to Stripe being the classic, is a 5-second fuse under every
other write in the application.
That is survivable and people survive it. It stops being survivable when you want a second machine,
because database: storage/production.sqlite3 is a path on a disk and not a host and a port. The
Kamal file rails new generates mounts dbq_storage:/rails/storage as a volume on the one host it
deploys to. Adding a second web server to that config is not a scaling step, it is two applications
with two different databases.
The pragma that reports the wrong thing
To show where those 5 seconds come from, the obvious move is to ask the connection:
$ bin/rails runner 'c = ActiveRecord::Base.lease_connection; puts c.select_value("PRAGMA busy_timeout")'
0
Zero. With timeout: 5000 sitting in database.yml and a measured wait of 5.013 seconds. That
reading sends you looking for a dropped key somewhere in DatabaseConfigurations. Nothing is
dropped.
sqlite3_adapter.rb:858 reads:
@raw_connection.busy_handler_timeout = timeout
busy_handler_timeout= is not busy_timeout=. The sqlite3 gem implements it as a Ruby-level busy
handler, and its own comment at sqlite3/database.rb:691 says why: "This is an alternative to
busy_timeout, which holds the GVL". PRAGMA busy_timeout reads back the C-level setting, which
Rails never touches, so it stays at 0 forever and tells you nothing. If you are trying to confirm
that your timeout: is applied, read
ActiveRecord::Base.connection_db_config.configuration_hash[:timeout] and then time an actual
contended write. The pragma is a dead end.
A column type that does not exist
While setting the migration test up I first used a bad type rather than a bad table:
add_column :hits, :nope, :this_type_does_not_exist
PostgreSQL raises ActiveRecord::StatementInvalid wrapping
PG::UndefinedObject: ERROR: type "this_type_does_not_exist" does not exist. MySQL raises
Trilogy::QueryError: 1064: You have an error in your SQL syntax. SQLite creates the column,
because SQLite has type affinity and will accept any type name you hand it:
[[0, "id", "INTEGER", 1, nil, 1], [1, "name", "varchar", 0, nil, 0], [2, "created_at", "datetime(6)", 1, nil, 0], [3, "updated_at", "datetime(6)", 1, nil, 0], [4, "first_col", "varchar", 0, nil, 0], [5, "nope", "this_type_does_not_exist", 0, nil, 0]]
The migration passes. The damage shows up in db/schema.rb, which Rails rewrites on every
successful migration:
ActiveRecord::Schema[8.1].define(version: 2026_09_27_090000) do
# Could not dump table "hits" because of following StandardError
# Unknown type 'this_type_does_not_exist' for column 'nope'
end
The table is gone from the schema file. schema_dumper.rb:196 raises on any column whose type is
not valid_type?, the per-table rescue turns it into that comment, and a green migration has just
left you a db/schema.rb that builds an empty database. Every new checkout running
bin/rails db:prepare against it gets no hits table at all.
The generator bug you will meet if you pick MariaDB
rails new -d mariadb-mysql produces a .github/workflows/ci.yml with this in it:
test:
runs-on: ubuntu-latest
services:
# redis:
# image: valkey/valkey:8
An empty services: key, no database container, and no DATABASE_URL in the test step's env:.
The cause is in the template, rails/generators/rails/app/templates/github/ci.yml.tt, line 91:
<%- if options[:database] == "mysql" || options[:database] == "trilogy" -%>
followed by elsif options[:database] == "postgresql" on line 99. "mariadb-mysql" equals neither,
so every branch is skipped. The same omission repeats for the system-test job at lines 164 and
- Diffing the two generated files is the fastest way to see it:
$ diff dbmy/.github/workflows/ci.yml dbmaria/.github/workflows/ci.yml
72,78d71
< mysql:
< image: mysql
...
101d93
< DATABASE_URL: mysql2://127.0.0.1:3306
Every line of the difference is a deletion. If you pick a MariaDB flag, write the service block yourself.
The tests
Nine Minitest cases hold the claims above, against all three live connections at once, in one file pinned to activerecord 8.1.4:
class WhichDatabaseTest < Minitest::Test
def conn(name) = BASES.fetch(name).lease_connection
def test_only_mysql_cannot_roll_back_a_failed_migration
assert_equal true, conn("SQLite").supports_ddl_transactions?
assert_equal true, conn("PostgreSQL").supports_ddl_transactions?
assert_equal false, conn("Trilogy").supports_ddl_transactions?
end
def test_the_timeout_key_does_not_land_in_pragma_busy_timeout
assert_equal 0, conn("SQLite").select_value("PRAGMA busy_timeout")
assert_equal 5000, SqliteBase.connection_db_config.configuration_hash[:timeout]
end
end
$ bundle exec ruby which_database_test.rb
Run options: --seed 23321
# Running:
.........
Finished in 5.032037s, 1.7885 runs/s, 6.1605 assertions/s.
9 runs, 31 assertions, 0 failures, 0 errors, 0 skips
The five seconds in that run time are one test: the SQLite busy timeout, asserted as
assert_operator waited, :>=, 5.0 and assert_operator waited, :<, 6.0 around a second
SQLite3::Database holding BEGIN IMMEDIATE.
The position
Use PostgreSQL unless you can say today that the application will only ever run on one machine.
The reason is not speed, and the measurements above are the argument that it is not: SQLite was the
faster of the two here on both writes and reads, under eight concurrent writers and single
threaded. The reason is that PostgreSQL is the only one of the three that has the constraint
primitives (supports_exclusion_constraints?, supports_unique_constraints?,
supports_nulls_not_distinct?), rolls back a half-applied migration, and lives at a host and a port
that a second process on a second box can reach. The price is an accessory to run, a connection
pool to size, and rails new -d postgresql giving you a config/database.yml with no host and no
port key at all, so a Postgres on a non-default port needs PGPORT in the environment or two
lines added by hand. That is what I did to run everything here.
What would change my answer: an application with one writer, one machine, and a backup story you
have actually restored from. Then the best database for Rails is the one rails new already gave
you, and Rails 8's four-file production block was designed for exactly that shape. A single-tenant
internal tool, a scheduled scraper, a personal service. The moment a second web process appears,
SQLite is not an option any more and no amount of tuning changes that.
Choosing MySQL for a new Rails application, I would want a reason beyond familiarity. The failed migration above is the whole argument.
What this page does not cover
Read performance under concurrency, which is where SQLite's story is strongest and where I measured
nothing. Migrating an existing application from one adapter to another, which is a different problem
from picking one. Managed hosting: I did not run anything on Heroku, RDS, Neon or Planetscale, so
this page makes no claim about what they give you. Connection poolers. Replication, read replicas,
and Rails' connects_to API. Vector columns, pgvector, and anything about what these three do
with JSON, which jsonb-columns-in-rails.md covers for PostgreSQL. And any database that is not in
the four adapters Rails registers.
Two things here get a paragraph and deserve a page, and have one. How config/database.yml
actually resolves, including the shared: block and what DATABASE_URL merges rather than
replaces, is rails-database-yml.md. Connecting to MySQL for real, including mysql2 against
trilogy and the socket behaviour that makes host: localhost ignore your port, is
rails-mysql.md; every MySQL number on this page came from trilogy against one server, and I did
not repeat it under mysql2.
Comments
No comments yet. Be the first.