LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 0 views

Connecting Rails to MySQL

rails new blog -d mysql gets you a config/database.yml that works on the laptop it was generated on and fails on the next machine, and the reason is not in the file. Half of what makes a Rails process reach a MySQL server is decided by the client library rather than by anything you wrote, and the two client libraries Rails supports disagree about it.

Everything below was run today against MySQL 26.7.0 (Homebrew, arm64), Ruby 4.0.5, activerecord 8.1.4, mysql2 0.5.7 and trilogy 2.13.0, on an Apple M2 Max. The comparisons against PostgreSQL 17.7 were run on the same laptop. Every fenced block was executed, and the test file at the bottom is the one that holds the claims.

The command, and the three files it edits

Two commands cover the starting position. A new application:

rails new blog -d mysql

An application that already exists and is on something else:

bin/rails db:system:change --to=mysql

Run against a fresh -d postgresql app under git, db:system:change --to=mysql touched exactly three files:

 Dockerfile          |  4 ++--
 Gemfile             |  2 +-
 config/database.yml | 60 ++++++++++++++---------------------------------------
 3 files changed, 18 insertions(+), 48 deletions(-)

The Gemfile change is one line, and it leaves the comment above it lying:

 # Use postgresql as the database for Active Record
-gem "pg", "~> 1.1"
+gem "mysql2", "~> 0.5"

edit_gemfile in railties-8.1.4/lib/rails/generators/rails/db/system/change/change_generator.rb gsubs the gem name and the gem entry, and nothing in it looks at the comment. Cosmetic, but it is the first thing a reviewer reads in the diff.

The Dockerfile change is not cosmetic. The base stage gets default-mysql-client in place of postgresql-client, and the build stage gets default-libmysqlclient-dev in place of libpq-dev. --to=trilogy removes libpq-dev and adds nothing to the build stage at all, because Rails::Generators::Database::Trilogy#build_package returns nil. The valid values for --to are in DATABASES at lib/rails/generators/database.rb:6: mysql, trilogy, postgresql, sqlite3, mariadb-mysql, mariadb-trilogy. Four of the six are MySQL protocol.

The socket path in your database.yml came from a directory listing

config/database.yml generated on this machine carries a hardcoded socket and no host:

default: &default
  adapter: mysql2
  encoding: utf8mb4
  max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password:
  socket: /tmp/mysql.sock

That path was not a default. Rails::Generators::Database::MySQL#socket, at railties-8.1.4/lib/rails/generators/database.rb:31, walks nine candidate paths and takes the first one that exists on the machine running the generator:

def socket
  @socket ||= [
    "/tmp/mysql.sock",                        # default
    "/var/run/mysqld/mysqld.sock",            # debian/gentoo
    "/var/tmp/mysql.sock",                    # freebsd
    "/var/lib/mysql/mysql.sock",              # fedora
    "/opt/local/lib/mysql/mysql.sock",        # fedora
    "/opt/local/var/run/mysqld/mysqld.sock",  # mac + darwinports + mysql
    "/opt/local/var/run/mysql4/mysqld.sock",  # mac + darwinports + mysql4
    "/opt/local/var/run/mysql5/mysqld.sock",  # mac + darwinports + mysql5
    "/opt/lampp/var/mysql/mysql.sock"         # xampp for linux
  ].find { |f| File.exist?(f) } unless Gem.win_platform?
end

So the committed file records where MySQL happened to live on one developer's laptop at one moment. A teammate on Debian gets the Debian path from the same repository and the error is this, which is the single most common "Rails cannot connect to MySQL" message there is:

Mysql2::Error::ConnectionError: Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock' (2)

If no candidate exists when you run rails new, the socket: line is omitted entirely and the default block has neither a host nor a socket. That still connects locally, because with nothing specified libmysqlclient uses the socket anyway, so the omission is invisible until the file reaches a container where there is no socket to use.

host: localhost under mysql2 is not a hostname

Under the Rails mysql2 adapter, localhost is a keyword meaning "use the unix socket", and the port: sitting next to it in the YAML is discarded. Here is the proof, asking the server itself how each connection arrived. performance_schema.threads.connection_type is the column, and it answers Socket, TCP/IP or SSL/TLS:

def connection_type(config)
  ActiveRecord::Base.establish_connection(config)
  id = ActiveRecord::Base.lease_connection.select_value("select connection_id()")
  ActiveRecord::Base.lease_connection.select_value(
    "select connection_type from performance_schema.threads where processlist_id = #{id}"
  )
ensure
  ActiveRecord::Base.remove_connection
end

Four configurations, one server, run through bin/rails runner:

mysql2  host: localhost  port: 3306   Socket
mysql2  host: localhost  port: 9999   Socket
mysql2  host: 127.0.0.1  port: 3306   SSL/TLS
trilogy host: localhost  port: 3306   TCP/IP

The second row is the one that costs an afternoon. Port 9999 has nothing listening on it, the connection succeeds anyway, and every conclusion you draw about your port configuration from a working bin/rails console is worthless. Change localhost to 127.0.0.1 and the same config fails honestly:

Mysql2::Error::ConnectionError: Can't connect to MySQL server on '127.0.0.1:9999' (61)

The third row is worth knowing for a different reason. Nothing in that config asked for encryption, and the server reported the connection as SSL/TLS anyway: mysql2 0.5.7 here links libmysqlclient.24.dylib, which upgrades a TCP connection when the server offers a certificate. Trilogy on the same address and port reported TCP/IP, in the clear. So an application that moved from mysql2 to trilogy with no other change, and whose MySQL is across a network, turned TLS off in that commit.

The trilogy template ships a host and a socket, and the socket wins

The Rails trilogy adapter gets a different template, and db:system:change --to=trilogy writes a database.yml that sets both keys: host in the shared default block, socket inside each environment.

default: &default
  adapter: trilogy
  encoding: utf8mb4
  max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password:
  host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %>

development:
  <<: *default
  socket: /tmp/mysql.sock
  database: blog_development

Feed that same pair of keys to each adapter and they resolve it in opposite directions:

trilogy  host: 127.0.0.1 + socket: /tmp/mysql.sock   Socket
mysql2   host: 127.0.0.1 + socket: /tmp/mysql.sock   SSL/TLS

Point the socket at a path that does not exist and the divergence gets worse. Trilogy fails:

ActiveRecord::ConnectionNotEstablished: No such file or directory - No such file or directory - connect(2) for /tmp/nope.sock

mysql2 answers SSL/TLS, because it never looked at the socket in the first place. So the file that comes out of --to=trilogy has one setting that does nothing under one adapter and overrides everything under the other, and setting DB_HOST to a remote server does nothing at all in development until you also delete the socket: line.

caching_sha2_password refuses you after the password is right

Access denied means the password is wrong. This is the other error, the one that arrives when the password is right:

Mysql2::Error: Authentication plugin 'caching_sha2_password' reported error: Authentication requires secure connection.

caching_sha2_password is the only password plugin this server will hand out. information_schema.plugins lists sha256_password and caching_sha2_password and nothing else, and every row of mysql.user on a stock install, root included, has caching_sha2_password in its plugin column. On a first login, before the server has cached the credential, it will accept the password over a TLS connection or as an RSA-encrypted blob, and refuses anything else. ssl_mode: disabled in database.yml removes the first option, and mysql2 does not take the second unless you ask.

Two settings fix it and one famous instruction does not. The famous one first, because it is the top answer on every forum thread carrying this message:

$ mysql -u root -e "ALTER USER 'app'@'%' IDENTIFIED WITH mysql_native_password BY 'secret'"
ERROR 1524 (HY000) at line 1: Plugin 'mysql_native_password' is not loaded

mysql_native_password is not loaded on this server and cannot be switched on, since it is not in information_schema.plugins either. Neither is the variable the same threads tell you to set in my.cnf:

$ mysql -u root -e "select @@default_authentication_plugin"
ERROR 1193 (HY000) at line 1: Unknown system variable 'default_authentication_plugin'

The variable that replaced it is authentication_policy, and it reads *,, here. So the two pieces of advice a search for this error returns first are both things this server will refuse to do, and an hour goes into finding that out one command at a time.

The two settings that get a caching_sha2_password login through

Picking one of the two paths the plugin actually offers is the whole fix. Ask for TLS, which is the right answer over a network and costs a handshake:

production:
  adapter: mysql2
  ssl_mode: required

Or stay in the clear and let the client fetch the server's RSA public key, which is the right answer inside a private network where TLS is a certificate you do not want to manage:

production:
  adapter: mysql2
  ssl_mode: disabled
  get_server_public_key: true

Both keys go straight through database.yml into Mysql2::Client.new, and both were verified by reading connection_type back off the server: SSL/TLS for the first, TCP/IP for the second. Note what the second one costs: the password crosses the wire encrypted under the server's public key, but every query and every result after it is plaintext.

Trilogy needs neither. It implements the RSA path itself, unconditionally, in trilogy-2.13.0/ext/trilogy-ruby/src/client.c at send_public_key_request (line 561) and encrypt_password_with_public_key (line 568). After a FLUSH PRIVILEGES to empty the server's credential cache, trilogy authenticated the same user over an unencrypted TCP connection where mysql2 with ssl_mode: disabled raised. Convenient, and easy to mistake for "trilogy handles security for me", which it is the opposite of.

mysql2 or trilogy

Trilogy, unless something specific stops you. The argument is the linker, not the benchmark.

$ otool -L mysql2-0.5.7/lib/mysql2/mysql2.bundle
mysql2-0.5.7/lib/mysql2/mysql2.bundle:
    /Users/x/.rvm/rubies/ruby-4.0.5/lib/libruby.4.0.dylib (compatibility version 4.0.0, current version 4.0.5)
    /opt/homebrew/opt/mysql/lib/libmysqlclient.24.dylib (compatibility version 24.0.0, current version 24.0.0)
    /opt/homebrew/opt/zlib/lib/libz.1.dylib (compatibility version 1.0.0, current version 1.3.2)
    /opt/homebrew/opt/zstd/lib/libzstd.1.dylib (compatibility version 1.0.0, current version 1.5.7)
    /opt/homebrew/opt/openssl@3/lib/libssl.3.dylib (compatibility version 3.0.0, current version 3.0.0)
    /opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib (compatibility version 3.0.0, current version 3.0.0)
    /usr/lib/libresolv.9.dylib (compatibility version 1.0.0, current version 1.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)

$ otool -L trilogy-2.13.0/lib/trilogy/cext.bundle
trilogy-2.13.0/lib/trilogy/cext.bundle:
    /Users/x/.rvm/rubies/ruby-4.0.5/lib/libruby.4.0.dylib (compatibility version 4.0.0, current version 4.0.5)
    /opt/homebrew/opt/openssl@3/lib/libssl.3.dylib (compatibility version 3.0.0, current version 3.0.0)
    /opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib (compatibility version 3.0.0, current version 3.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)

(Run from the gem directory, with the home directory in the libruby line shortened to /Users/x. Nothing else is edited.)

mysql2 needs Oracle's client library present at build time and on the image at runtime, which is what default-libmysqlclient-dev in the Dockerfile buys and what this looks like when it is missing:

Cannot find include dir(s) /opt/nonexistent/include
*** extconf.rb failed ***
Could not create Makefile due to some reason, probably lack of necessary
libraries and/or headers.

Trilogy has no such dependency. It is a MySQL protocol implementation in C with OpenSSL for the TLS, which is why its Dockerfile build stage is shorter and why its gem installs on a machine that has never had MySQL on it.

Speed is not the argument. 5000 iterations of Post.where("views < ?", 500).limit(20).to_a against a 1000-row table, over the unix socket, two runs each:

mysql2   socket   0.417 ms/query, then 0.398
trilogy  socket   0.359 ms/query, then 0.362

Around 10% on a query that does almost nothing, which is the best case for a difference in driver overhead and still not a reason to migrate anything. Do not compare the TCP numbers from the same run: mysql2 was doing TLS and trilogy was not, so that column measures encryption, not drivers.

The cost of switching is real and it is not the gem line. Error classes change. The same rejected index reaches Ruby as two different messages, both wrapped in ActiveRecord::StatementInvalid:

Mysql2::Error: BLOB/TEXT column 'body' used in key specification without a key length
Trilogy::ProtocolError: 1170: BLOB/TEXT column 'body' used in key specification without a key length (trilogy_query_recv)

Any rescue Mysql2::Error in your codebase stops rescuing on the day you switch, silently, and the trilogy message carries the MySQL error number where the mysql2 one does not. Grep for the constant before you change the adapter.

A failed migration leaves the schema half applied

MySQL commits DDL implicitly, which means a Rails migration is not atomic. Here is the whole difference, run through the same Active Record on both servers:

ActiveRecord::Base.transaction do
  c.execute("create table ddl_probe (id int)")
  c.execute("insert into ddl_probe values (1)")
  raise ActiveRecord::Rollback
end
exists = c.table_exists?(:ddl_probe)
rows = exists ? c.select_value("select count(*) from ddl_probe") : nil
puts "#{label}: after ROLLBACK, table exists=#{exists}, rows=#{rows.inspect}"
postgresql 17.7: after ROLLBACK, table exists=false, rows=nil
mysql 26.7.0   : after ROLLBACK, table exists=true, rows=1

The CREATE TABLE committed itself, and it took the INSERT after it along. Play that out in a migration that creates two tables where the second one is wrong:

class TwoTables < ActiveRecord::Migration[8.1]
  def change
    create_table :alpha do |t|
      t.string :name
    end

    create_table :beta do |t|
      t.text :body, index: true
    end
  end
end
== 20260927090000 TwoTables: migrating ========================================
-- create_table(:alpha)
   -> 0.0042s
-- create_table(:beta)
bin/rails aborted!
StandardError: An error has occurred, all later migrations canceled: (StandardError)

Mysql2::Error: BLOB/TEXT column 'body' used in key specification without a key length
db/migrate/20260927090000_two_tables.rb:7:in 'TwoTables#change'

After that, alpha is in show tables and 20260927090000 is not in schema_migrations. Fix the migration, run it again, and the retry dies on the wreckage of the first attempt:

Mysql2::Error: Table 'alpha' already exists

Somebody has to drop alpha by hand before the migration can run, and on a production database that somebody is doing it at the point in the deploy where the new code is already out. The working practice is the one that is good manners on PostgreSQL and mandatory here: one schema change per migration file, and never a create_table and a data backfill in the same one. The rest of that argument, including the disable_ddl_transaction! cases where PostgreSQL gives up atomicity too, is in migrations that do not break, and the same failed migration run side by side on SQLite, PostgreSQL and MySQL is in what database Rails uses.

REPEATABLE READ is the default, and PostgreSQL's is not

@@global.transaction_isolation on this server is REPEATABLE-READ. show transaction_isolation on PostgreSQL 17.7 is read committed. An application ported between the two gets a different answer from the same code with nothing in the diff to point at.

What the difference looks like: one connection opens a transaction and reads a row twice, another connection commits a new value in between.

REPEATABLE-READ: first read 1, then another connection committed 99, second read 1
READ-COMMITTED:  first read 1, then another connection committed 99, second read 99

Under the MySQL default, every read inside ActiveRecord::Base.transaction is served from the snapshot taken at the first read of that transaction. A job that opens a transaction, calls an external API for two seconds, then re-reads the record to decide what to do is reading two-second-old data and cannot be made to see otherwise. Under PostgreSQL's default the second read is current. Neither is a bug. They are different defaults, and only one of them matches what the code was written against.

Set it per connection in database.yml if you are porting, rather than arguing about it in each job:

default: &default
  adapter: trilogy
  variables:
    transaction_isolation: "READ-COMMITTED"

Verified by reading @@session.transaction_isolation back after boot. The cost is that the isolation level is not only a visibility setting in InnoDB, and the MySQL 8.4 reference manual is explicit about both halves. On locking: under READ COMMITTED, "InnoDB locks only index records, not the gaps before them, and thus permits the free insertion of new records next to locked records", which changes which concurrent workloads deadlock. On replication: "Only row-based binary logging is supported with the READ COMMITTED isolation level." Neither was measured here; both are quoted from the manual page on InnoDB transaction isolation levels. If you inherited a server running binlog_format=STATEMENT, that second sentence is the one to settle before you change anything.

Four column defaults that are not the shape you left in Postgres

t.decimal :price with no precision is the expensive one. The identical migration on PostgreSQL 17.7 produced a bare numeric column that stored 0.1999e2. On MySQL:

price decimal(10,0) DEFAULT NULL

Scale zero. create!(price: 19.99) returned a record whose price is 19 before it ever reached the database, because Active Record's decimal type casts to the declared scale on assignment. No exception, no warning, and the cents are gone. Write t.decimal :price, precision: 10, scale: 2, which produced decimal(10,2) and kept 0.1999e2, and which is the advice in money and decimals for every database and merely load-bearing on this one.

t.text is 65535 bytes, not unlimited. 65535 bytes stored; one byte more raised ActiveRecord::ValueTooLong: Mysql2::Error: Data too long for column 'body' at row 1. The same column on PostgreSQL 17.7 took a 200000 byte string without comment. If the column holds anything user-generated, declare t.text :body, limit: 16.megabytes, which Active Record renders as longtext here and which did store 200000 bytes.

Indexes have a 3072 byte key limit and utf8mb4 counts four bytes per character, so a varchar(255) column costs 1020 bytes of it. Three of them fit in one composite index and four do not:

Mysql2::Error: Specified key was too long; max key length is 3072 bytes

Indexing a TEXT column at all needs a prefix length, which add_index has no argument for in the shorthand form:

Mysql2::Error: BLOB/TEXT column 'body' used in key specification without a key length

t.boolean is tinyint(1), which round-trips as true and false through Active Record and as 1 and 0 through bin/rails dbconsole. Worth knowing when you are reading production data by hand; otherwise not something you have to manage.

The database.yml worth committing

Two changes to what the generator writes, both verified by booting the app against them:

default: &default
  adapter: trilogy
  encoding: utf8mb4
  collation: utf8mb4_0900_ai_ci
  max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  host: <%= ENV.fetch("MYSQL_HOST", "127.0.0.1") %>
  port: <%= ENV.fetch("MYSQL_PORT", 3306) %>
  username: <%= ENV.fetch("MYSQL_USER", "root") %>
  password: <%= ENV["MYSQL_PASSWORD"] %>
  variables:
    transaction_isolation: "READ-COMMITTED"

development:
  <<: *default
  database: blog_development

No socket: anywhere, so every environment reaches the server the same way and a wrong port is an error instead of a silent fallback. 127.0.0.1 rather than localhost, for the same reason. The variables: block is a per-session SET, so it applies to every connection in the pool without touching my.cnf. Booted against that file, bin/rails runner printed:

Trilogy
READ-COMMITTED
posts=1000

max_connections: is what the Rails 8.1 generator writes where older applications have pool:, and setting both to different numbers raises RuntimeError: Ambiguous configuration before anything connects. That key, the shared: block and what DATABASE_URL does to the rest of the file are the subject of Rails database.yml, resolved, and none of it is adapter-specific.

The test file

Twenty-four examples across three files in a scratch app, holding every claim above that a test can hold:

$ bin/rails test test/models/
Run options: --seed 30024

# Running:

........................

Finished in 0.473327s, 50.7049 runs/s, 101.4098 assertions/s.
24 runs, 48 assertions, 0 failures, 0 errors, 0 skips

One of them had to be written twice, and the second version is the article in miniature. The schema tests open with create_table, which under the default transactional test wrapper produced:

ActiveRecord::StatementInvalid: Mysql2::Error: SAVEPOINT active_record_1 does not exist

The implicit commit had ended the transaction Rails opened around the example, so the savepoint it tried to release afterwards was gone. Four examples errored for a reason that had nothing to do with what they were testing. self.use_transactional_tests = false on that class is the fix, and any test class of yours that runs DDL needs the same line.

What this page does not cover

MariaDB, which Rails treats as a separate preconfiguration (mariadb-mysql and mariadb-trilogy in that DATABASES list) and whose authentication, JSON type and sequence support all differ from what is above. Replication and read replicas, where Rails 8.1's connects_to and automatic role switching are the same code on every adapter but the failover semantics are not. FULLTEXT indexes, which are the MySQL answer to the thing PostgreSQL full text search does with tsvector, and which deserve their own measurements rather than a paragraph here. Managed MySQL on RDS, PlanetScale or Vitess, none of which run on this laptop, so nothing about their connection limits or their DDL behaviour appears above. And every benchmark of MySQL against PostgreSQL, because the only two numbers here are driver overhead on one query shape on one machine, and a page that turned those into a verdict about the servers would be inventing the part that matters.

The scratch app that produced all of it is a plain rails new blog -d mysql plus three test files. Nothing in it is specific to this machine except the socket path, which is the point of the second section.

#rails #active-record #mysql

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.