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

Rails multitenancy, with the boundary in the database

The query is always the same one under the marketing words: how do I stop account A from reading account B's rows, and know that I stopped it. Almost every answer to it starts with default_scope { where(account_id: Current.account_id) }, and that line is not an answer to that question. It is an answer to a different and much smaller one, which is how to avoid typing .where(account_id:) on every query.

Everything below ran on this laptop against activerecord 8.1.3.1, Ruby 4.0.5 and PostgreSQL 17.7 (Homebrew) on an Apple M2 Max, in a scratch database called mt_lab on port 15432. Two roles matter and they are both real: mehdifarsi, who owns the tables, and mt_app, created for these runs with CREATE ROLE mt_app LOGIN PASSWORD 'mt_app' and four table grants. Every fenced block below is copied out of a terminal.

Three designs, and which one this page argues for

Three shapes exist and the names are stable. One shared schema with an account_id column on every tenant-owned table. One PostgreSQL schema per tenant, the same tables repeated under tenant_41.tasks, selected with SET search_path. One database per tenant, selected with a different connection.

The position of this page: shared schema, account_id on every tenant-owned table, and the boundary enforced by a PostgreSQL row level security policy rather than by Ruby. The other two are answers to a question about contracts, not a question about isolation, and the numbers in "Schema per tenant, and the number where it stops" below are what they cost.

What would change it: a customer contract that requires physical separation, or one tenant whose working set no longer fits beside the others. Neither of those is an isolation argument. Both are real anyway, and both are reasons to move one tenant out rather than to shard all of them from day one.

default_scope is the ergonomics, not the boundary

default_scope does more than its reputation suggests. Here is the concern that every tenant-owned model includes, and it is four lines:

class Current < ActiveSupport::CurrentAttributes
  attribute :account_id
end

module Tenanted
  extend ActiveSupport::Concern
  included do
    default_scope { where(account_id: Current.account_id) }
    before_validation { self.account_id ||= Current.account_id }
  end
end

With two accounts in the table and Current.account_id set to the second one, build_default_scope in active_record/scoping/default.rb:145 reaches further than the lore says. It scopes the association's ON clause too, which is the leak people still warn about:

== Current.account_id = 2 (globex) ==
Project.count            => 1
Project.pluck(:name)     => ["B-proj"]
Task.pluck(:title)       => ["B-task"]
Project.new.account_id   => 2
Project.find(other)      => ActiveRecord::RecordNotFound: Couldn't find Project with 'id'=1 [WHERE "projects"."account

-- the joins --
Project.joins(:tasks).to_sql:
  SELECT "projects".* FROM "projects" INNER JOIN "tasks" ON "tasks"."account_id" = 2 AND "tasks"."project_id" = "projects"."id" WHERE "projects"."account_id" = 2
Project.joins(:tasks).pluck(:name) => ["B-proj"]

Note ON "tasks"."account_id" = 2. The joined model's default scope is in the join condition on activerecord 8.1.3.1, so Task's scope applies even though the query was built from Project. That is better than it used to be and it is still not a boundary, because everything that does not go through the relation builder walks straight past it:

-- raw sql --
select_values => ["A-task", "B-task"]

That line is one find_by_sql, one select_all in a reporting query, one count in a Solid Queue job written by somebody who did not know, one gem. What a Rails scope actually returns covers what default_scope does to new, to update_all and to an ORDER BY; the only claim being made here is about what it does not cover. The whole mechanism lives in the process that built the query, and the thing you want to be true is a property of the data.

The boundary is a row level security policy

A row level security policy moves the predicate into the database, where every query goes through it, including the ones nobody wrote in Ruby. The migration is ordinary except that three of its statements have no Rails DSL:

class IsolateDocumentsByAccount < ActiveRecord::Migration[8.1]
  TABLES = %i[documents]

  def up
    create_table :documents do |t|
      t.references :account, null: false
      t.string :title, null: false
      t.timestamps
    end

    TABLES.each do |table|
      execute <<~SQL
        ALTER TABLE #{table} ENABLE ROW LEVEL SECURITY;
        ALTER TABLE #{table} FORCE ROW LEVEL SECURITY;
        CREATE POLICY account_isolation ON #{table}
          USING     (account_id = nullif(current_setting('app.account_id', true), '')::bigint)
          WITH CHECK (account_id = nullif(current_setting('app.account_id', true), '')::bigint);
        GRANT SELECT, INSERT, UPDATE, DELETE ON #{table} TO mt_app;
      SQL
    end
    execute "GRANT USAGE, SELECT ON SEQUENCE documents_id_seq TO mt_app"
  end

  def down
    TABLES.each { |table| execute "DROP POLICY account_isolation ON #{table}" }
    drop_table :documents
  end
end
==  IsolateDocumentsByAccount: migrating ======================================
-- create_table(:documents)
   -> 0.0336s
-- execute("ALTER TABLE documents ENABLE ROW LEVEL SECURITY;\nALTER TABLE documents FORCE ROW LEVEL SECURITY;\n...")
   -> 0.0012s
-- execute("GRANT USAGE, SELECT ON SEQUENCE documents_id_seq TO mt_app")
   -> 0.0009s
==  IsolateDocumentsByAccount: migrated (0.0358s) =============================

relrowsecurity / relforcerowsecurity: [[true, true]]
policy: [["account_isolation", "*", "(account_id = (NULLIF(current_setting('app.account_id'::text, true), ''::text))::bigint)"]]

polcmd is *, meaning the one policy covers SELECT, INSERT, UPDATE and DELETE. Omit the WITH CHECK clause and PostgreSQL reuses USING for writes, which is the behaviour you want; it is written out above so that the next person editing it does not have to know that.

The request sets the tenant once, inside a transaction, and the rest of the request is unchanged Rails. Here are two identical tables, notes with the policy and plain_notes without it, each holding one row for account 1 and one for account 2, both read as account 2:

what runs plain_notes, default_scope only notes, policy as well
pluck(:body) ["globex note"] ["globex note"]
select_values("select body from ...") ["acme note", "globex note"] ["globex note"]
unscoped.count 2 1
create!(account_id: 1) inserts PG::InsufficientPrivilege: ERROR: new row violates row-level security policy for table "notes"
where(body: "acme note").delete_all 1 0

Ten of those cells are ten of the sixteen tests in the Minitest file this page was written from:

$ bundle exec ruby isolation_test.rb
Run options: --seed 51278

# Running:

.WARNING:  SET LOCAL can only be used in transaction blocks
...............

Finished in 0.324290s, 49.3386 runs/s, 67.8405 assertions/s.

16 runs, 22 assertions, 0 failures, 0 errors, 0 skips

The stray WARNING in the middle of the dots is not decoration. It is the subject of two sections further down.

schema.rb does not carry the policy, and structure.sql does

ActiveRecord::SchemaDumper has no idea row level security exists. Immediately after the migration above ran green, here is everything the Ruby dumper wrote about documents:

  create_table "documents", force: :cascade do |t|
    t.index ["account_id"], name: "index_documents_on_account_id"

No ENABLE ROW LEVEL SECURITY, no FORCE, no CREATE POLICY, no GRANT. A db:schema:load from that file builds a table with no tenant boundary at all, which means CI runs green against an unprotected database and a restored staging copy is wide open. pg_dump --schema-only on the same table wrote all of it:

38:ALTER TABLE ONLY public.documents FORCE ROW LEVEL SECURITY;
90:CREATE POLICY account_isolation ON public.documents USING ((account_id = (NULLIF(current_setting('app.account_id'::text, true), ''::text))::bigint)) WITH CHECK ((account_id = (NULLIF(current_setting('app.account_id'::text, true), ''::text))::bigint));
97:ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
103:GRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.documents TO mt_app;

So config.active_record.schema_format = :sql is not a preference in this design, it is a prerequisite, and it is the first thing to change. The cost is the one everybody knows: a structure.sql that no longer reads as Ruby, and a diff on every migration that includes whatever else pg_dump decided to reformat. Pay it. The alternative is a boundary that exists in production and in no other copy of the database.

Two reasons the policy did nothing the first time

The first run of this experiment returned both tenants' rows with the policy in place, and then kept returning both after FORCE ROW LEVEL SECURITY was added, which is the point where a reasonable person concludes that row level security does not work:

select '-- as owner (mehdifarsi), RLS enabled --' as step;
 id | account_id | title
----+------------+--------
  1 |          1 | A-task
  2 |          2 | B-task
(2 rows)

Two separate causes, stacked. The role was a superuser:

select rolname, rolsuper, rolbypassrls from pg_roles where rolname = current_user;
  rolname   | rolsuper | rolbypassrls
------------+----------+--------------
 mehdifarsi | t        | t
(1 row)

A superuser, and any role with BYPASSRLS, ignores every policy on every table, with no warning and no log line. The default Homebrew PostgreSQL role is a superuser, which means the developer machine where you test your isolation is the one machine where isolation is switched off.

And underneath that, ENABLE ROW LEVEL SECURITY exempts the table's owner. Rails migrations run as the owner, so if the application connects with the same credentials as the migrations, the policy is inert for every query the application makes. FORCE ROW LEVEL SECURITY closes that, and the enforceable version of the rule is a second role: mt_app with SELECT, INSERT, UPDATE, DELETE grants and nothing else. Proof that the separation is doing something, from the benchmark script trying to toggle the policy off:

PG::InsufficientPrivilege: ERROR:  must be owner of table events

The cost of the second role is real and it is mostly a deployment cost: two sets of credentials in the secrets, a GRANT in every migration that creates a table, and a class of failure where a new table works in development and 403s in production because the grant was forgotten. A GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mt_app plus ALTER DEFAULT PRIVILEGES removes most of it.

SET LOCAL needs a transaction, and RESET does not undo it

SET LOCAL is scoped to a transaction, and outside one it does nothing at all. What it does instead is print a warning to the process's stderr, not to the Rails log, and register the parameter as an empty string:

WARNING:  SET LOCAL can only be used in transaction blocks

-- 1. SET LOCAL outside a transaction --
current_setting => ""
Task.pluck(:title) => ActiveRecord::StatementInvalid: PG::InvalidTextRepresentation: ERROR:  invalid input syntax for type bigint: ""

Inside a transaction the same two lines behave:

-- 2. SET LOCAL inside a transaction --
current_setting => "2"
Task.pluck(:title) => ["B-task"]
raw select_values => ["B-task"]
Task.unscoped.count => 1
delete_all => 0

The empty string is worth a second look, because it also arrives by a route that has nothing to do with transactions. RESET does not return a custom parameter to unset, it returns it to its boot default, which for a placeholder GUC is ''. So a policy written the obvious way survives never having been set, and dies after the first RESET:

### GUC never set
 count
-------
     0

### after SET then RESET
 count
-------
     1
(1 row)

RESET
 guc_is_empty_string_not_null
------------------------------
 t
(1 row)

ERROR:  invalid input syntax for type bigint: ""

That is the whole reason for the nullif(current_setting('app.account_id', true), '') in the migration above rather than a bare cast. The true handles never set, the nullif handles reset, and the result of both is NULL, which makes account_id = NULL unknown and the policy match nothing. Failing to zero rows is the correct direction to fail, and it is also a miserable thing to debug: the page renders, the list is empty, and no exception is raised anywhere. Log the resolved tenant on every request.

The pooled connection remembers the last tenant

A connection returned to the Active Record pool keeps its session state. SET without LOCAL therefore outlives the request that issued it, and the next request to check that connection out inherits somebody else's tenant:

-- 4. session-level SET, then give the connection back to the pool --
  inside the block => ["A-task"]
  next checkout, current_setting => "1"
  next checkout, rows => ["A-task"]

The same is true of SET search_path, which is what makes it the central hazard of schema per tenant rather than a footnote:

-- 4. does search_path survive a return to the pool --
  next checkout search_path => "tenant_9, public"

Two ways out. SET LOCAL inside a transaction that wraps the request, which is self-cleaning and costs you a transaction open for the whole request. Or a session SET with a RESET in an ensure, which survives an exception in the middle of the request:

def test_resetting_the_guc_on_the_way_out_closes_the_leak
  AppRecord.connection_pool.with_connection do |c|
    c.execute("SET app.account_id = '1'")
    raise "the request blew up"
  rescue RuntimeError
  ensure
    c.execute("RESET app.account_id")
  end
  after = AppRecord.connection_pool.with_connection { |c| c.select_values("select body from notes") }
  assert_equal [], after, "RESET in an ensure block survives an exception in the request"
end

Green. And note what it asserts: [], not the right tenant's rows, because RESET left the empty string and nullif turned it into NULL.

Current is empty at every executor boundary

ActiveSupport::CurrentAttributes is reset at every executor boundary, which is every request and every job. The wiring is the active_support.reset_execution_context initializer at activesupport-8.1.3.1/lib/active_support/railtie.rb:49, and this test reproduces exactly what it installs:

def test_current_attributes_are_reset_at_every_executor_boundary
  # exactly what the "active_support.reset_execution_context" initializer wires up
  executor = Class.new(ActiveSupport::Executor)
  executor.to_run { ActiveSupport::ExecutionContext.push }
  executor.to_complete do
    ActiveSupport::CurrentAttributes.clear_all
    ActiveSupport::ExecutionContext.pop
  end
  Current.account_id = 2
  inside = :unset
  executor.wrap { inside = Current.account_id }
  assert_nil inside, "a request or a job starts with Current empty, whatever set it last"
  assert_nil Current.account_id
end

A new Thread sees nil for a second reason: ActiveSupport::IsolatedExecutionState.isolation_level is :thread here, so a thread you spawn yourself starts with its own empty state. So every job carries its tenant in its arguments or it has no tenant, and with a default_scope that means it silently sees nothing, while with a policy it means the first query returns zero rows. Both are wrong quietly. The habit that fixes it is passing the account as the first argument of every job and setting both Current and the GUC in one around_perform, which is a Solid Queue or Sidekiq middleware either way.

What the policy costs

Warm buffer cache, one connection, 500,000 rows in events across 50 accounts, 10,000 of them belonging to account 7, alternating RLS on and off across five rounds. Milliseconds per operation, from the last three rounds:

operation RLS off RLS on
point lookup by primary key 0.0546 - 0.0612 0.0624 - 0.0656
count(*) over the tenant's 10,000 rows 0.3332 - 0.3392 0.4028 - 0.4066
newest 20 for the tenant 0.0539 - 0.0622 0.0567 - 0.0571
transaction with no SET LOCAL 0.0984 - 0.1037
transaction plus SET LOCAL 0.1242 - 0.1407

So: nothing a benchmark on this machine can separate from noise on a primary key lookup, about 0.07 ms on a ten thousand row aggregate, and about 0.03 ms for the extra round trip per request. The planner hoists the predicate out of the loop, which is why the aggregate is not worse:

Aggregate  (cost=230.42..230.43 rows=1 width=8) (actual time=1.627..1.627 rows=1 loops=1)
  ->  Result  (cost=0.42..206.17 rows=9700 width=0) (actual time=0.022..1.167 rows=10000 loops=1)
        One-Time Filter: ((current_setting('app.account_id'::text))::bigint = 7)
        ->  Index Only Scan using index_events_on_account_id on events  (cost=0.42..206.17 rows=9700 width=0)
              Index Cond: (account_id = 7)
              Heap Fetches: 0

The first version of this benchmark said the policy made a primary key lookup 44 times slower: 3.3801 ms against 0.0770 ms. It was wrong, and it was wrong in the most ordinary way there is. The RLS-on run went first, on a cold buffer cache, and warmed 62 MB of table for the RLS-off run that followed. Alternating the order and discarding the first round collapsed the difference to the table above. Any multitenancy benchmark that reports a single figure without saying which run went first is reporting the buffer cache.

The index that turns 31 milliseconds into 0.03

One thing about row-level tenancy really is expensive, and it is not the policy. Every index that serves a tenant-scoped query has to lead with the tenant column, and the query that catches people out is the small tenant on a large table. Twenty rows for account 999 among 500,000, with only (created_at desc) indexed because that is what the ORDER BY asked for:

### only (created_at) indexed, 20-row tenant on 500k rows
 Limit (actual time=29.821..31.637 rows=20 loops=1)
   Buffers: shared hit=1894 read=3111 written=2
   ->  Gather Merge (actual time=29.817..31.631 rows=20 loops=1)
         Workers Planned: 2
         ->  Parallel Index Scan using index_events_on_created_at on events (actual time=17.164..17.208 rows=7 loops=3)
               Filter: (account_id = 999)
               Rows Removed by Filter: 166667
 Execution Time: 31.659 ms
### after adding (account_id, created_at desc)
 Limit (actual time=0.017..0.019 rows=20 loops=1)
   Buffers: shared hit=4 read=3
   ->  Index Scan using index_events_on_account_id_and_created_at on events (actual time=0.016..0.018 rows=20 loops=1)
         Index Cond: (account_id = 999)
 Execution Time: 0.028 ms

31.659 ms to 0.028 ms, and 5,005 buffers to 7. The composite index is 3,464 kB against 3,408 kB for the single-column one, so the storage argument against it does not exist. What does exist is that you now own this rule for every index in the schema forever, and the query that breaks it will be one somebody adds in a year. Rails N+1 queries, from includes to strict_loading is the other half of the same habit.

Schema per tenant, and the number where it stops

Schema per tenant is the design this page is arguing against, and the argument is arithmetic rather than taste. One thousand schemas of ten tables each, created on this laptop:

create 1000 schemas x 10 tables: 15512 ms
pg_class rows: 445 -> 60445
catalog size:  194 MB
one add_column across 1000 schemas: 310 ms  (0.31 ms each)
one add_index across 1000 schemas:  298 ms
information_schema.columns for tenant schemas: 31000 rows in 175 ms

The migration numbers are the reassuring ones: 310 ms to add a column to a thousand tenants is fine. The catalog numbers are the ones that bite, and structure.sql is where you meet them first:

$ pg_dump -h localhost -p 15432 -d mt_lab --schema-only -f structure_1000.sql
-rw-r--r--  1 mehdifarsi  wheel    12M Sep 27 18:00 structure_1000.sql
  577407 structure_1000.sql

$ pg_dump -h localhost -p 15432 -d mt_lab --schema-only --schema=public -f structure_public.sql
-rw-r--r--  1 mehdifarsi  wheel   8.7K Sep 27 18:00 structure_public.sql
     400 structure_public.sql

A 12 MB, 577,407 line file in git, rewritten on every migration, against 400 lines for the shared schema holding the same model definitions. And the version bound to schema_format = :sql, which is the same thing row level security requires, so you do not get to escape it by choosing schema.rb.

The hard stop is the lock table. Dropping the tenant schemas in one transaction, which is what any loop over tenants inside a Rails migration is, ended like this:

ERROR:  out of shared memory
HINT:  You might need to increase "max_locks_per_transaction".
CONTEXT:  SQL statement "drop schema tenant_122 cascade"

The arithmetic behind that message, measured rather than inferred:

begin;
-- drop 10 tenant schemas
select count(*) as locks_held_after_10_schema_drops from pg_locks where pid = pg_backend_pid();
 locks_held_after_10_schema_drops
----------------------------------
                             1023

 lock_slots | max_locks_per_transaction x max_connections
------------+---------------------------------------------
       6400 |                                        6400

102 locks per tenant schema of ten tables, and 6,400 slots on a server with the default max_locks_per_transaction = 64 and max_connections = 100. Sixty-odd tenants per transaction, which is why the cleanup here had to run in batches of fifty. Every operation that must touch all tenants atomically is capped, and the cap does not move with hardware, it moves with a setting that costs shared memory for every backend whether it uses the locks or not.

Rails has one more surprise in this design and it is not in the catalog. The schema cache is per process, not per tenant:

-- 3. the schema cache is not per tenant --
Task.column_names in tenant_1 => ["id", "name", "created_at", "due_on"]
Task.column_names after switching to tenant_2 (no reset) => ["id", "name", "created_at", "due_on"]
Task.column_names after reset_column_information => ["id", "name", "created_at", "due_on", "tenant_2_only"]

tenant_2.tasks has a column tenant_1.tasks does not, and Task.column_names kept reporting the first tenant's columns until reset_column_information, which throws the cache away for every tenant. Any migration that lands on tenants one at a time leaves the process holding a column list that is right for some tenants and wrong for others, and there is no per-tenant cache to fix it with.

The prepared statement bug that did not reproduce

Schema switching has a famous failure: PostgreSQL caches a plan bound to a table's OID, the search_path changes, and the cached plan keeps reading the previous tenant's table. It does not happen on PostgreSQL 17.7. The same prepared statement, forced past the five custom plans that trigger a generic one:

set search_path to tenant_1, public;
prepare t (int) as select name from tasks where id = $1;
execute t(1);  -- x6, all returning:
            name
----------------------------
 task belonging to tenant 1

set search_path to tenant_2, public;
execute t(1);
            name
----------------------------
 task belonging to tenant 2

select name, generic_plans, custom_plans from pg_prepared_statements;
 name | generic_plans | custom_plans
------+---------------+--------------
 t    |             2 |            5

Two generic plans, five custom ones, and the right answer after the switch: the plan cache tracks search_path and replans. The same test through Active Record with prepared_statements = true agreed. Anybody quoting this bug as a current reason to avoid schema switching is quoting a PostgreSQL that has not shipped in years. The reasons in the section above are enough on their own.

Database per tenant is a contract, not an architecture

Connections are the ceiling, and it is lower than it looks. Opening plain PG.connect sessions until the server refused:

opened 86 connections, then: connection to server at "::1", port 15432 failed: FATAL:  sorry, too many clients already
max_connections = 100
superuser_reserved_connections = 3

One backend held 1,302 kB across its memory contexts, from select pg_size_pretty(sum(total_bytes)) from pg_backend_memory_contexts. A Rails process holding pool: 5 per tenant runs out at seventeen tenants on this server, and every additional Puma worker multiplies it.

The blocker usually named for this design turned out not to be one. connects_to shards: reads like a boot-time declaration, and the assumption that a new tenant therefore needs a deploy is wrong. An unknown shard fails the way you would hope:

unknown shard at runtime => ActiveRecord::ConnectionNotDefined: No database connection defined for ShardedRecord with 'tenant_late' shard.

Then, in the same process, appending an ActiveRecord::DatabaseConfigurations::HashConfig to ActiveRecord::Base.configurations.configurations and calling connects_to again with the full shard hash:

-- now add the shard at runtime and retry --
read from the late shard => ["late tenant widget"]
current_database        => "mt_tenant_late"

The real cost is that connects_to has to be called with every shard, not the new one, and it has to happen in every process: each Puma worker, each job worker, each console. Signing up a tenant now means a message to N processes that may or may not be listening, or a lazy check on every request that a shard is registered. That is a distributed systems problem bolted to a signup form.

So: one database per tenant when a contract or a regulator requires the separation, and for the one tenant that has outgrown the others. Not as the default, and not for isolation, which the policy already did for 0.03 ms.

The call, and what would change it

Shared schema. account_id on every tenant-owned table, NOT NULL, indexed as the leading column of every index that serves a tenant query. A row level security policy per table, FORCEd, with nullif(current_setting('app.account_id', true), '')::bigint. schema_format = :sql. A second database role with DML grants and nothing else. SET LOCAL in a transaction that wraps the request, and the tenant resolved and logged before the first query. default_scope on top of all of it, because typing .where(account_id:) five hundred times is how the five-hundred-and-first gets forgotten, but understood as ergonomics that happen to agree with the boundary rather than as the boundary.

What would change it. A managed PostgreSQL that does not grant the DDL needed for CREATE POLICY, or a connection pooler mandated in transaction mode with a framework that cannot issue SET LOCAL, would push the boundary back into Ruby and make the honest answer "scope everything and review every query", which is a worse answer by a lot and is sometimes the only one available. A tenant count in the tens with one very large tenant would change the database-per-tenant line, because the operational cost of a second database is fixed and the benefit scales with how different the tenants are. Nothing in the numbers above would change it, which is the point of measuring them.

What this post does not cover

Resolving the tenant from the request. Subdomain, path prefix, custom domain and JWT claim all end at the same Current.account_id = ..., the trade-offs are about DNS and TLS certificates rather than about isolation, and none of it was measured here. The acts_as_tenant and ros-apartment gems, which were not installed for any of these runs and so are not described: everything above is plain Active Record and plain PostgreSQL. Cross-tenant work that legitimately has to see every row, which means an admin console, billing rollups and the search index, and which needs a second role or a BYPASSRLS grant and a very short list of places that use it. Backup and restore for a single tenant, which is the one place schema per tenant genuinely wins and where pg_dump --schema=tenant_41 is a complete answer that the shared schema has no equivalent for. Sharding for volume rather than for isolation. And connection poolers in transaction mode: PgBouncer is not installed on this machine and nothing here went through one, so what transaction pooling does to a session-level SET is an open question this page does not answer, and the SET LOCAL variant is the one to reach for if you are behind a pooler and cannot test it.

All numbers were taken on one laptop, single connection, warm cache, with the run order alternated. Reproduce them before trusting them: the scripts are a few dozen lines each and the only thing they need is a PostgreSQL to point at.

#rails #postgresql #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.