The project is in a healthy, maintained state
At deploy time, activerecord-precompiled rewrites simple ActiveRecord query chains in your application source (e.g. `User.where(name: n).first`) into direct calls on the SQLite3 raw connection with SQL generated by ActiveRecord itself. The original expression is kept as a fallback for any case the fast path does not cover.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 8.0, < 8.3
>= 1.2
 Project Readme

activerecord-precompiled

CI

assets:precompile for your ActiveRecord queries, on SQLite. You write normal ActiveRecord; at deploy time, on production only, the query chains in your source are rewritten into precompiled SQL that runs straight on the sqlite3 handle: no Arel, no relation objects. Logs, APM and the query cache see them as usual.

# what you write (and commit)
def self.named(name) = User.find_by(name: name)

# what runs in production after `rails ar_precompile:build`
def self.named(name) = ::ActiveRecordPrecompiled::Sites::S9802ca3c9c_0.call(name) { |__xs0| User.find_by(name: __xs0) }
BEGIN { ::ActiveRecordPrecompiled::Site.define(:S9802ca3c9c_0, model: "User", kind: :one, sql: "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"name\" = ? LIMIT ?", binds: [[:arg, 0, "name"], [:val, 1, "LIMIT"]], schema: "e79cff1769c2ac6f") } # activerecord-precompiled v0.1.0

Same idea as ruby-next's nextify (source-to-source at build time) and bootsnap (do the expensive work once, ahead of time), applied to queries.

How it works

  • SQL comes from ActiveRecord itself. For each chain, the build runs the real ActiveRecord method with placeholder arguments and intercepts the query at the adapter. The SQL is byte-for-byte what ActiveRecord would send.
  • Binds are cast like ActiveRecord does it: type_for_attribute(col).serialize, then connection.type_cast, so enums, booleans, times, and decimals behave the same.
  • Execution goes through with_raw_connection, using the adapter's own prepared statement pool. Locking, lazy transactions, and reconnects behave as they do in ActiveRecord.
  • The original chain is kept as a fallback block. It runs for nil, Range, Relation, or record arguments (and arrays ActiveRecord wouldn't turn into a plain IN list), inside scoping/unscoped blocks, on non-SQLite connections, when ActiveRecordPrecompiled.enabled = false, and to raise RecordNotFound for find/find_by!/first!/take! misses.
  • Line numbers are preserved: site definitions go into one BEGIN {} line at the end of the file.
  • Schema guard. Each site records a fingerprint of its table's columns, including their order. If the runtime schema differs, the site logs one warning and uses ActiveRecord from then on.

Supported

Chains rooted at a model constant: all, where(col: v, ...), where.not(col: v, ...), order(literals), group(:cols), limit(int), offset(int), includes(literals), preload(literals), static scopes, followed by to_a, first, last, take, first!, take!, find, find_by, find_by!, count, exists?, exists?(col: v, ...), exists?(id), pluck(:cols), sum/maximum/minimum/average(:col), or one of each, map, flat_map, filter_map, each_with_index, each_with_object, index_by, group_by with a block. order also takes Arel.sql("...") with a plain string. Calculations return what ActiveRecord does (the column's type; average of an integer or decimal column is a BigDecimal; sum of nothing is 0). With group, count and the calculations return ActiveRecord's Hash (keys cast with the columns' types, an Array key for several columns); having is left to ActiveRecord. exists?(id) takes the fast path for an Integer or String id; false, nil, Arrays, Hashes and records mean something else to ActiveRecord and go to it.

includes/preload run the main query on the fast path and then preload, as Relation#load does. Each association's query is precompiled (ActiveRecord's SQL for key = ? and key IN (?, ...), up to 100 keys) for associations whose scope is a fixed where/order. For has_many/has_one :through and has_and_belongs_to_many that is the two queries the Preloader runs for them (owners to the through records, then to the targets), each checked at runtime against the scope ActiveRecord builds for it.

When every association in an includes tree has one, the Site preloads the tree without building the Preloader's objects or the association scopes. It follows what Preloader::Batch does, in the same order: rounds of runnable loaders, loaders deferred while another branch will still load their table, loaders with equal queries loaded by one query (so, as with ActiveRecord, a comment's author can be the very object that is its post.user), grouping by owner, inverses and already-loaded targets. A through association takes this path when its two inner preloads assign what they load (no conditions, order or distinct on it). Otherwise the Preloader runs as usual and only its queries are precompiled. A through query that ActiveRecord joins (a where on the through association, source_type), nested through associations, polymorphic ones and scopes that read the clock stay with ActiveRecord. If includes would JOIN instead (conditions or order on the included table), or with pluck, the chain is left to ActiveRecord.

A condition value may be an Array at runtime: where(id: ids) runs ActiveRecord's id IN (?, ...) (NOT IN for where.not) for any length up to 100, and a one-element array the scalar SQL, as ActiveRecord does. The build checks the IN form against ActiveRecord for each argument. Empty arrays, arrays with nil, ranges or records, and longer ones go to ActiveRecord.

Chains can also start at a collection association (has_many, has_many :through, has_and_belongs_to_many): user.posts.where(title: t).first, @trip.trip_days.count, or photos.maximum(:position) inside a model. The receiver's class isn't known at build time, so each model with an association of that name whose scopes are static gets its own Site, with the owner's key as a bind; at runtime the CollectionProxy tells which owner it is. Records point back to the owner (inverse) and take its strict_loading mode, as ActiveRecord's AssociationRelation does. ActiveRecord runs the chain for a new or strict-loading owner, an owner class the build didn't see, and, when the terminator is called on the association itself (posts.first), when the association is loaded or holds unsaved records, since ActiveRecord may answer from memory then. posts.to_a/posts.each (which load the association into the owner) are left alone.

A condition key can also be a belongs_to association: where(site: site) binds site.id to site_id, as ActiveRecord does (an id works too). Polymorphic associations are left to ActiveRecord.

Model.friendly.find(id) from friendly_id compiles too, for :slugged and :history setups with an integer primary key and FriendlyId's own finders: the slug, old-slug, and primary-key lookups are each precompiled and tried in FriendlyId's order. A miss runs the original chain, so it raises FriendlyId's own RecordNotFound.

Deterministic encrypted attributes (encrypts :email, deterministic: true) work as condition keys: the argument is encrypted at runtime with the attribute's type, like ActiveRecord does. With extend_queries, the extra values it adds (the clear text, previous schemes) are bound too. A value that isn't a String, or an encryption setup that differs from the build's, goes to ActiveRecord. Constants compared to an encrypted attribute aren't compiled, since their ciphertext would be made with the build machine's keys.

With ar_lazy_preload's auto_preload on, fast-path records get the same lazy-preload context that Relation#load gives them, so their associations still load in batches.

Scopes are compiled when their body is static: one chain of all/where/ where.not/order/limit/offset/includes/preload and other static scopes, with literals or the scope's own parameters as values (ranges included). Scopes Rails generates (enum values, with_rich_text_*, with_attached_*) count as static too:

scope :adults, -> { where(age: 18..) }              # compiled
scope :named, ->(n) { where(name: n) }              # compiled
scope :born_since, ->(t) { where(born_at: t..) }    # compiled
scope :recent, -> { where(created_at: 1.week.ago..) } # not: evaluated per call

To see which scopes compile and how to fix the rest:

bin/rails ar_precompile:scopes        # or: bundle exec ar-precompile scopes
✅ User.adults                test/test_helper.rb:58
❌ User.recent                app/models/user.rb:12
   `1.week.ago` in `created_at:` is computed when the scope runs; make it a parameter:
   ->(x) { where(created_at: x..) } and pass the value from the caller
activerecord-precompiled: 14 of 19 scopes can be compiled (plus 6 enum scopes)

Bare constants resolve the way Ruby does: inside module Admin, User means Admin::User if it exists.

Everything else is left alone: non-static scopes, joins, eager_load (and includes that would JOIN), string conditions, chains on a relation held in a variable, models with default_scope, keys that are neither columns nor belongs_to associations, and arguments that are ranges or heredocs. STI subclasses work.

Deploy

gem "activerecord-precompiled"

Run once per release, after db:migrate and before the app boots:

RAILS_ENV=production bin/rails ar_precompile:build   # DRY_RUN=1 to preview

or bundle exec ar-precompile [--dry-run] [--force] [paths] from the Rails root. Paths default to app and lib; for the rake task, set AR_PRECOMPILE_PATHS=app,lib/foo. It refuses to run outside production unless FORCE=1 / --force, it prints every skipped call site with the reason, and running it again changes nothing. It never writes a file that doesn't parse. Never commit the rewritten files.

Building without the database (docker build)

The build needs the production schema (columns, in order, with their types), never data. Dump it where the production database is, and commit the file:

RAILS_ENV=production bin/rails ar_precompile:schema:dump   # or: bundle exec ar-precompile schema-dump
# writes config/ar_precompile_schema_cache.yml (ActiveRecord's schema cache format)

Then the build runs anywhere, the image build included:

RUN SECRET_KEY_BASE_DUMMY=1 RAILS_ENV=production bin/rails ar_precompile:build

When a SQLite database's file is missing, the build uses its dump on an empty in-memory database, so it writes no database file into the image. The output is the same as a build against the production database. A database file with no tables counts as missing, and the build removes it: that is what an initializer or gem that connects while the app loads leaves behind, and the app would otherwise start on it in production. When the dump is older than your newest migrations, the build recreates its tables (columns in order, keys, indexes) and runs those migrations on them, as the deploy will on production: SQLite adds a column last, so the order comes out the same. Dump again once they have run on production. A migration that can't run on empty tables (one that needs data, or a view) leaves the dump as it is, with a warning; the schema guard then notices at runtime, and the queries it changes run on ActiveRecord. Other databases (Solid Cache, Queue, Cable) don't need a dump: without one, queries on them are left to ActiveRecord. AR_PRECOMPILE_SCHEMA=dump uses the dumps even when the database is there, and =database never uses them.

The dump is kept apart from ActiveRecord's own db/schema_cache.yml on purpose: the app would load that one at boot, and the schema guard has to see the real database.

Trade-offs

  • Fast-path queries are instrumented like ActiveRecord's own: a sql.active_record event with the same payload (sql, name, binds as named attributes, type_casted_binds, connection, row_count, ...) and precompiled: true. So they show up in the Rails log (with filter_attributes applied), APM, query counts (assert_queries_count) and the request's "ActiveRecord: ...ms (N queries)". The event costs about as much as ActiveRecord's (a few microseconds; Rails always has a subscriber). To skip it: ActiveRecordPrecompiled.active_record_events = false. They are also published as sql.active_record_precompiled (payload sql, name, binds, row_count), which costs nothing when nobody subscribes.
  • With ActiveRecord's query cache on (as in every request), fast-path results are cached in the connection's own cache store, so they live exactly as long as ActiveRecord's (any write clears them; uncached skips them). Entries aren't shared with uncompiled calls of the same query. A hit is logged and counted as CACHE, as ActiveRecord's are.
  • SQL is fixed at build time, so build against the production schema: after db:migrate on the production database, or from its dump. Column order matters, and it is the order of the database's own migration history; a database loaded from schema.rb has the order of whichever database dumped it. The schema guard catches a mismatch, but those sites then run on ActiveRecord.
  • Works with query_log_tags_enabled, which turns prepared statements off: the build turns them on just while capturing SQL, and the runtime always binds. Fast-path queries don't carry the query-log comment.

Results on a real app (golf-buddy, 720 Ruby files)

  • 407 call sites rewritten in 183 files (about 190 of them on associations).
  • Every rewritten site with data in the dev database (172 of them) returned results identical to ActiveRecord, preloaded associations included. Query cache off on both sides, fast-path queries instrumented as sql.active_record: median 1.98× faster, geomean 2.17× (2.33× and 2.61× with active_record_events = false). Medians by kind: association count/exists? 3.0–3.5×, exists? 2.6×, friendly.find 2.5×, count 2.3×, single-record lookups about 1.8× (loading the record dominates on wide models). Given five-element arrays instead, the 62 sites with IN lists matched too.
  • The app's test suite (4317 runs, same seed, one worker) gives the same results before and after the rewrite: the same 3 failures, which are in the clean baseline too.
  • Whole requests (12 public pages, production mode, dev data, fragment cache off, query cache on): 1.6% less time in total. Pages run 15–175 queries, of which the fast path takes 1–19; venue pages (4–6 fast-path queries) are 4–6% faster, the rest within noise. Most remaining queries are lazy association loads (venue.venue_photos, photo.image inside loops), which no rewritten call site covers.

Requirements

Ruby 3.4+, ActiveRecord 8.0–8.2 (8.2 tested against Rails main, see Gemfile.edge; 8.0 with Gemfile.rails80), the sqlite3 adapter (the sqlite3 gem, or on JRuby activerecord-jdbc's sqlite3 adapter). JRuby 10 works with Rails 8.1 and activerecord-jdbc's master branch, which is the only activerecord-jdbc for Rails 8 so far (Gemfile.jruby): the fast path runs the precompiled SQL on its JDBC connection, about 1.3–2.2× faster than ActiveRecord there.

The build checks that it can still capture queries before touching any file. If a Rails change breaks that, it stops with ActiveRecordPrecompiled::Capture::Broken instead of finishing without rewriting anything.

Development

bundle exec rake            # differential tests: every case runs through AR and the rewrite
bundle exec rake test:edge  # the same against Rails main
BUNDLE_GEMFILE=Gemfile.rails80 bundle exec rake test  # ActiveRecord 8.0
BUNDLE_GEMFILE=Gemfile.jruby bundle exec rake test    # JRuby (see Gemfile.jruby)
bundle exec ruby bench/bench.rb