LaunchKit
← All posts
· 15 min read · by The LaunchKit team · 1 views

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

  1. 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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.