<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Prateek Codes - Building Scalable Backend Systems</title>
    <description>Learn how to build scalable backend systems with Ruby on Rails, PostgreSQL optimization, database scaling, and practical engineering insights from real-world production experiences.</description>
    <link>https://prateekcodes.com/</link>
    <atom:link href="https://prateekcodes.com/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Sun, 05 Apr 2026 10:42:37 +0000</pubDate>
    <lastBuildDate>Sun, 05 Apr 2026 10:42:37 +0000</lastBuildDate>
    <generator>Jekyll v4.4.1</generator>
    
      <item>
        <title>Stop Pasting Schema Into Your AI: Connect PostgreSQL Directly with MCP</title>
        <description>&lt;p&gt;Agentic coding tools have gotten good at reading your codebase. Claude Code will find your &lt;code&gt;schema.rb&lt;/code&gt;, Cursor will pick up your Prisma schema, and most tools know how to navigate ORM-based projects well enough to understand your data model structurally.&lt;/p&gt;

&lt;p&gt;What they can’t do is reason about your actual data - and that gap matters more than most developers realize.&lt;/p&gt;

&lt;p&gt;When you ask an AI to help design a new feature, it’s working from structure alone. It knows what columns exist, not how they’re used. It doesn’t know that your &lt;code&gt;notifications&lt;/code&gt; table has 400M rows and any fan-out design will be a problem. It doesn’t know that 80% of your &lt;code&gt;users&lt;/code&gt; have never set a &lt;code&gt;preferences&lt;/code&gt; value, which changes how you’d model the feature. It doesn’t know whether a background job is necessary or whether the data volume makes a synchronous approach fine. These are the tradeoffs that determine whether a feature ships well or causes incidents - and without live data access, the AI is guessing.&lt;/p&gt;

&lt;p&gt;The same gap shows up in debugging. When a query is slow, the AI has to ask you to run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; and paste the results. When it needs row counts or value distributions, it asks you to run those too. You become the relay between the AI and your database.&lt;/p&gt;

&lt;p&gt;Model Context Protocol (MCP) is an open standard that lets AI tools connect to external systems and query them directly. With a Postgres MCP server configured, your AI can inspect live data volumes, run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, check value distributions, and make recommendations grounded in what’s actually in your database.&lt;/p&gt;

&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;

&lt;p&gt;Here’s what designing a new feature looks like without MCP:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; “I want to add a real-time notification feed for users.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; “How many users do you have, and roughly how many notifications per user per day?”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; &lt;em&gt;(checks, pastes numbers)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; “And what does the current &lt;code&gt;notifications&lt;/code&gt; table look like? How many rows total?”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; &lt;em&gt;(runs &lt;code&gt;SELECT COUNT(*)&lt;/code&gt;, pastes result)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; “What’s the read pattern - are users mostly reading recent notifications or querying by type?”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; &lt;em&gt;(runs a distribution query, pastes result)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AI knows your schema. It has no idea what’s in it. Every data-dependent decision becomes a round-trip where you run the query and report back. For a non-trivial feature this happens five or ten times before you get a recommendation you can act on.&lt;/p&gt;

&lt;h2 id=&quot;with-a-postgres-mcp-server&quot;&gt;With a Postgres MCP Server&lt;/h2&gt;

&lt;p&gt;Once connected, the same conversation looks like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; “I want to add a real-time notification feed for users.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; &lt;em&gt;(checks &lt;code&gt;notifications&lt;/code&gt; table row count, queries user and notification distribution, inspects existing indexes)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI:&lt;/strong&gt; “You have 400M notification rows across 2.1M users, with a long tail - the top 5% of users account for 60% of rows. A naive fan-out on write will be a problem at this scale. I’d recommend a pull-based feed with a covering index on &lt;code&gt;(user_id, created_at DESC)&lt;/code&gt; rather than pre-computing feeds. Here’s the schema and the background job design.”&lt;/p&gt;

&lt;p&gt;No relay. The AI queries your database when it needs data and gives you a recommendation grounded in actual numbers. The same applies to debugging - understanding &lt;a href=&quot;/postgresql-explain-analyze-deep-dive/&quot;&gt;query plans&lt;/a&gt; becomes something the AI does directly rather than something you transcribe into the conversation.&lt;/p&gt;

&lt;h2 id=&quot;setting-up-modelcontextprotocolserver-postgres&quot;&gt;Setting Up @modelcontextprotocol/server-postgres&lt;/h2&gt;

&lt;p&gt;The official Postgres MCP server is &lt;code&gt;@modelcontextprotocol/server-postgres&lt;/code&gt;. The source repository was archived in May 2025, but the npm package remains functional and is the most straightforward way to get started.&lt;/p&gt;

&lt;h3 id=&quot;step-1-create-a-read-only-database-user&quot;&gt;Step 1: Create a Read-Only Database User&lt;/h3&gt;

&lt;p&gt;Never connect your AI tool using the same credentials your application uses. Create a dedicated user with read-only access:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Create a dedicated user for AI access
CREATE USER ai_readonly WITH PASSWORD &apos;your-secure-password&apos;;

-- Grant connect on the database
GRANT CONNECT ON DATABASE your_database TO ai_readonly;

-- Grant schema usage
GRANT USAGE ON SCHEMA public TO ai_readonly;

-- Grant read-only access to all current tables
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ai_readonly;

-- Ensure future tables are also covered
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO ai_readonly;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This limits blast radius significantly. Worth noting: the MCP server also runs all queries inside a &lt;code&gt;READ ONLY&lt;/code&gt; transaction - the README states this explicitly - so it refuses mutations at the server level regardless of user permissions. The read-only DB user is a second, independent layer of protection. Both should be in place.&lt;/p&gt;

&lt;h3 id=&quot;step-2-configure-claude-desktop-or-claude-code&quot;&gt;Step 2: Configure Claude Desktop or Claude Code&lt;/h3&gt;

&lt;p&gt;For &lt;strong&gt;Claude Desktop&lt;/strong&gt;, edit &lt;code&gt;~/Library/Application Support/Claude/claude_desktop_config.json&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;mcpServers&quot;: {
    &quot;postgres&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [
        &quot;-y&quot;,
        &quot;@modelcontextprotocol/server-postgres&quot;,
        &quot;postgresql://ai_readonly:your-secure-password@localhost:5432/your_database&quot;
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For &lt;strong&gt;Claude Code&lt;/strong&gt;, open &lt;code&gt;~/.claude/settings.json&lt;/code&gt; and add the same &lt;code&gt;mcpServers&lt;/code&gt; block:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;mcpServers&quot;: {
    &quot;postgres&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [
        &quot;-y&quot;,
        &quot;@modelcontextprotocol/server-postgres&quot;,
        &quot;postgresql://ai_readonly:your-secure-password@localhost:5432/your_database&quot;
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Restart Claude after saving.&lt;/p&gt;

&lt;h3 id=&quot;step-3-verify-the-connection&quot;&gt;Step 3: Verify the Connection&lt;/h3&gt;

&lt;p&gt;Ask Claude: “What tables are in my database?”&lt;/p&gt;

&lt;p&gt;If it responds with your actual schema, you’re connected. The server exposes two capabilities to the AI:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Schema inspection&lt;/strong&gt; - lists tables, columns, data types, constraints, and indexes&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Query execution&lt;/strong&gt; - runs SQL and returns results into the conversation context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s enough for the AI to write accurate migrations, suggest indexes based on actual table sizes, and debug slow queries by running &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; itself.&lt;/p&gt;

&lt;h2 id=&quot;other-mcp-database-options&quot;&gt;Other MCP Database Options&lt;/h2&gt;

&lt;p&gt;The official Postgres server works well for most local setups, but depending on your database host or what you need the AI to do, there are more capable alternatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://neon.tech/docs/mcp&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Neon MCP documentation (opens in new tab)&quot;&gt;Neon MCP&lt;/a&gt;&lt;/strong&gt; is worth using if you’re on Neon’s serverless Postgres. It’s actively maintained, supports both read and write operations through proper authorization, and integrates branch management. This makes it practical for letting the AI help apply migrations against a staging branch without touching production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase MCP&lt;/strong&gt; ships as part of Supabase’s tooling and gives the AI access to your project’s tables, schema, and Row Level Security policies. Useful if your authorization logic lives in the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite MCP&lt;/strong&gt; (&lt;code&gt;@modelcontextprotocol/server-sqlite&lt;/code&gt;) is the equivalent for local SQLite databases.&lt;/p&gt;

&lt;p&gt;For cloud-hosted databases - RDS, Azure Database, Cloud SQL - the configuration is identical to the local example above. If you’re on a primary-replica setup, point the connection string at the replica rather than the primary.&lt;/p&gt;

&lt;h2 id=&quot;security&quot;&gt;Security&lt;/h2&gt;

&lt;h3 id=&quot;use-a-read-only-connection&quot;&gt;Use a Read-Only Connection&lt;/h3&gt;

&lt;p&gt;Use a dedicated database user with &lt;code&gt;SELECT&lt;/code&gt;-only privileges, as shown in Step 1. The MCP server’s built-in transaction protection is a safety net, not a substitute for database-level access control.&lt;/p&gt;

&lt;h3 id=&quot;zero-data-retention-for-sensitive-databases&quot;&gt;Zero Data Retention for Sensitive Databases&lt;/h3&gt;

&lt;p&gt;If your database contains PII, financial records, or anything you’d classify as sensitive, pay attention to your AI provider’s data retention policy. When the AI queries your database through MCP, the results - including actual row data - pass through the conversation context. That context may be retained by default.&lt;/p&gt;

&lt;p&gt;For sensitive workloads:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Enable zero data retention (ZDR)&lt;/strong&gt; through Anthropic’s API if it’s available on your plan. With ZDR enabled, prompts and outputs aren’t stored or used for model training.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Avoid querying raw PII through the AI&lt;/strong&gt; - have the AI write the query, review it yourself, then run it outside the AI context.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Mask sensitive columns&lt;/strong&gt; using a view that exposes only what the AI needs:&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Expose a masked version of the users table
CREATE VIEW public.users_masked AS
SELECT
  id,
  created_at,
  updated_at,
  role,
  subscription_plan,
  &apos;***&apos; AS email,
  &apos;***&apos; AS phone_number
FROM users;

-- Grant access to the masked view only
GRANT SELECT ON public.users_masked TO ai_readonly;
REVOKE SELECT ON public.users FROM ai_readonly;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The AI still has enough context to answer questions about user behavior, subscription distribution, and counts - without seeing the actual values.&lt;/p&gt;

&lt;h2 id=&quot;what-changes-day-to-day&quot;&gt;What Changes Day to Day&lt;/h2&gt;

&lt;p&gt;Once the MCP server is running, the AI can:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Understand your full schema without you explaining it&lt;/li&gt;
  &lt;li&gt;Suggest indexes based on actual table sizes and existing constraints&lt;/li&gt;
  &lt;li&gt;Write migrations that account for real foreign keys and default values&lt;/li&gt;
  &lt;li&gt;Debug slow queries by running &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; itself&lt;/li&gt;
  &lt;li&gt;Answer data questions like “how many orders shipped last week?” directly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The manual loop of pasting schema, waiting for a clarifying question, then pasting more schema mostly disappears.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;The Postgres MCP server takes under ten minutes to set up, runs all queries in read-only transactions, and removes a class of manual work that compounds fast. Start with a dedicated read-only DB user, be deliberate about what data flows through the AI’s context, and you have a setup that’s practical and secure.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Official postgres MCP server repository on GitHub (opens in new tab)&quot;&gt;@modelcontextprotocol/server-postgres on GitHub&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://modelcontextprotocol.io&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Model Context Protocol official documentation (opens in new tab)&quot;&gt;Model Context Protocol documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://neon.tech/docs/mcp&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Neon MCP server documentation (opens in new tab)&quot;&gt;Neon MCP documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.anthropic.com/legal/privacy&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Anthropic privacy policy and data retention information (opens in new tab)&quot;&gt;Anthropic privacy policy and data retention&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Sun, 05 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/connect-your-ai-to-postgres-with-mcp/</link>
        <guid isPermaLink="true">https://prateekcodes.com/connect-your-ai-to-postgres-with-mcp/</guid>
        
        <category>mcp</category>
        
        <category>postgresql</category>
        
        <category>model-context-protocol</category>
        
        <category>claude</category>
        
        <category>cursor</category>
        
        <category>ai-tools</category>
        
        <category>database</category>
        
        <category>developer-productivity</category>
        
        
        <category>AI</category>
        
        <category>PostgreSQL</category>
        
        <category>MCP</category>
        
        <category>Developer Tools</category>
        
      </item>
    
      <item>
        <title>Rails 8.2 adds this_week?, this_month?, and this_year? to Date and Time</title>
        <description>&lt;p&gt;ActiveSupport already has &lt;code&gt;today?&lt;/code&gt;, &lt;code&gt;yesterday?&lt;/code&gt;, and &lt;code&gt;tomorrow?&lt;/code&gt; on &lt;code&gt;Date&lt;/code&gt; and &lt;code&gt;Time&lt;/code&gt;. Rails 8.2 adds the next logical set: &lt;code&gt;this_week?&lt;/code&gt;, &lt;code&gt;this_month?&lt;/code&gt;, and &lt;code&gt;this_year?&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;before&quot;&gt;Before&lt;/h2&gt;

&lt;p&gt;Checking whether a date falls within the current week, month, or year required range comparisons:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Is this order from the current week?
order.placed_at.between?(Time.current.beginning_of_week, Time.current.end_of_week)

# Is this subscription expiring this month?
subscription.expires_on.between?(Date.current.beginning_of_month, Date.current.end_of_month)

# Is this event happening this year?
event.date.year == Date.current.year
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Each check is readable enough on its own, but they add up quickly in controllers and views where you are branching on date ranges.&lt;/p&gt;

&lt;h2 id=&quot;rails-82&quot;&gt;Rails 8.2&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55770&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 55770 adding this_week? this_month? this_year? (opens in new tab)&quot;&gt;PR #55770&lt;/a&gt; introduces three new predicate methods on &lt;code&gt;Date&lt;/code&gt; and &lt;code&gt;Time&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;order.placed_at.this_week?           # true if within the current week
subscription.expires_on.this_month?  # true if within the current month
event.date.this_year?                # true if within the current year
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;They follow the same pattern as the existing predicates:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;Date.current.this_week?   # =&amp;gt; true
Date.current.this_month?  # =&amp;gt; true
Date.current.this_year?   # =&amp;gt; true

Date.yesterday.this_week? # =&amp;gt; true (yesterday is still this week, usually)
Date.current.next_month.this_month? # =&amp;gt; false
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;in-controllers&quot;&gt;In controllers&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;def index
  @orders = Order.all
  @this_week_orders = @orders.select { |o| o.placed_at.this_week? }
  @this_month_orders = @orders.select { |o| o.placed_at.this_month? }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;in-views&quot;&gt;In views&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-erb&quot;&gt;&amp;lt;% if subscription.expires_on.this_month? %&amp;gt;
  &amp;lt;div class=&quot;warning&quot;&amp;gt;Your subscription expires this month.&amp;lt;/div&amp;gt;
&amp;lt;% end %&amp;gt;

&amp;lt;% if report.generated_at.this_week? %&amp;gt;
  &amp;lt;span class=&quot;badge&quot;&amp;gt;Recent&amp;lt;/span&amp;gt;
&amp;lt;% end %&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;in-scopes&quot;&gt;In scopes&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Order &amp;lt; ApplicationRecord
  scope :placed_this_week, -&amp;gt; { where(placed_at: Time.current.beginning_of_week..Time.current.end_of_week) }
  scope :placed_this_month, -&amp;gt; { where(placed_at: Time.current.beginning_of_month..Time.current.end_of_month) }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The predicate methods on instances complement these scopes when working with already-loaded records.&lt;/p&gt;

&lt;h2 id=&quot;how-to-change-the-week-boundary&quot;&gt;How to change the week boundary&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;this_week?&lt;/code&gt; uses Monday as the start of the week by default, consistent with ActiveSupport’s &lt;code&gt;beginning_of_week&lt;/code&gt;. If your application configures a different week start, that is respected:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;Date.beginning_of_week = :sunday
Date.current.beginning_of_week  # =&amp;gt; last Sunday
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;this_week?&lt;/code&gt;, &lt;code&gt;this_month?&lt;/code&gt;, and &lt;code&gt;this_year?&lt;/code&gt; are small additions that remove a common category of boilerplate. They complete the set of readable date predicates that ActiveSupport has offered since Rails 3.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55770&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 55770 adding this_week? this_month? this_year? (opens in new tab)&quot;&gt;Pull Request #55770&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://api.rubyonrails.org/classes/ActiveSupport/CoreExt/Date/Calculations.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;ActiveSupport Date Calculations documentation (opens in new tab)&quot;&gt;ActiveSupport Date Calculations documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-82-this-week-this-month-this-year/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-82-this-week-this-month-this-year/</guid>
        
        <category>rails-8-2</category>
        
        <category>activesupport</category>
        
        <category>date</category>
        
        <category>time</category>
        
        <category>predicates</category>
        
        
        <category>Rails</category>
        
        <category>Rails 8.2</category>
        
        <category>ActiveSupport</category>
        
      </item>
    
      <item>
        <title>Rails 8.2 lets retry_on read the error when calculating wait time</title>
        <description>&lt;p&gt;Active Job’s &lt;code&gt;retry_on&lt;/code&gt; accepts a &lt;code&gt;wait:&lt;/code&gt; proc for custom backoff logic. Before Rails 8.2, that proc only received the execution count. When a remote API returns a &lt;code&gt;Retry-After&lt;/code&gt; header, there was no way to use that value inside the proc. Rails 8.2 fixes this by passing the exception as a second argument.&lt;/p&gt;

&lt;h2 id=&quot;before&quot;&gt;Before&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;wait:&lt;/code&gt; proc only knew how many times the job had been attempted:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class PaymentSyncJob &amp;lt; ApplicationJob
  retry_on Stripe::RateLimitError,
           attempts: 5,
           wait: -&amp;gt;(executions) { executions * 10 }

  def perform(order_id)
    Stripe::Charge.retrieve(order_id)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If Stripe responded with a &lt;code&gt;Retry-After: 30&lt;/code&gt; header, the job ignored it. The wait time was always based on the execution count, regardless of what the API actually asked for.&lt;/p&gt;

&lt;p&gt;To work around this, teams typically stored retry delay information on the exception class itself and then retrieved it through other means, which added boilerplate and coupling.&lt;/p&gt;

&lt;h2 id=&quot;rails-82&quot;&gt;Rails 8.2&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/56601&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 56601 allowing retry_on wait procs to accept the error (opens in new tab)&quot;&gt;PR #56601&lt;/a&gt; allows the &lt;code&gt;wait:&lt;/code&gt; proc to accept the exception as a second argument. Rails checks the proc’s arity, so existing one-argument procs continue to work without any changes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class PaymentSyncJob &amp;lt; ApplicationJob
  retry_on Stripe::RateLimitError,
           attempts: 5,
           wait: -&amp;gt;(executions, error) { error.retry_after || executions * 10 }

  def perform(order_id)
    Stripe::Charge.retrieve(order_id)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;When the job retries, it calls the proc with both the execution count and the exception. If the error has a &lt;code&gt;retry_after&lt;/code&gt; value, that gets used. Otherwise, it falls back to the execution-based formula.&lt;/p&gt;

&lt;p&gt;This works for any error class that exposes delay information:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class ExternalApiJob &amp;lt; ApplicationJob
  retry_on ApiRateLimitError,
           attempts: 10,
           wait: -&amp;gt;(executions, error) do
             # Use the header value if available, cap at 5 minutes
             [error.retry_after || executions ** 2, 300].min
           end

  def perform(resource_id)
    ExternalApi.fetch(resource_id)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;backward-compatibility&quot;&gt;Backward Compatibility&lt;/h2&gt;

&lt;p&gt;The change is fully backward compatible. A proc with one argument behaves exactly as before:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Still works, receives only executions
retry_on SomeError, wait: -&amp;gt;(executions) { executions * 5 }

# New behavior, receives both
retry_on SomeError, wait: -&amp;gt;(executions, error) { error.retry_after || executions * 5 }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Rails uses Ruby’s &lt;code&gt;arity&lt;/code&gt; to determine which form the proc uses and calls it accordingly.&lt;/p&gt;

&lt;h2 id=&quot;when-to-use-this&quot;&gt;When to Use This&lt;/h2&gt;

&lt;p&gt;Use the two-argument form when:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The API you call returns a &lt;code&gt;Retry-After&lt;/code&gt; header or equivalent&lt;/li&gt;
  &lt;li&gt;Your error class already captures the suggested wait time&lt;/li&gt;
  &lt;li&gt;You want backoff logic that adapts to what the remote service requests rather than using a fixed formula&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Rails 8.2 makes retry logic more accurate for jobs that talk to rate-limited APIs. By exposing the exception to the &lt;code&gt;wait:&lt;/code&gt; proc, jobs can respect what the remote service actually asks for instead of guessing.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/56601&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 56601 retry_on error-aware wait proc (opens in new tab)&quot;&gt;Pull Request #56601&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://api.rubyonrails.org/classes/ActiveJob/Exceptions/ClassMethods.html#method-i-retry_on&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Active Job retry_on API documentation (opens in new tab)&quot;&gt;Active Job retry_on documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-82-retry-on-error-aware-wait/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-82-retry-on-error-aware-wait/</guid>
        
        <category>rails-8-2</category>
        
        <category>active-job</category>
        
        <category>retry</category>
        
        <category>background-jobs</category>
        
        <category>error-handling</category>
        
        
        <category>Rails</category>
        
        <category>Rails 8.2</category>
        
        <category>Active Job</category>
        
      </item>
    
      <item>
        <title>Ruby::Box Practical Guide: Use Cases and Integration Patterns (Part 2)</title>
        <description>&lt;p&gt;In &lt;a href=&quot;/ruby-4-introduces-ruby-box-for-in-process-isolation-part-1/&quot;&gt;Part 1&lt;/a&gt;, we covered what &lt;code&gt;Ruby::Box&lt;/code&gt; is and how it provides namespace isolation. Now let’s explore practical patterns for integrating it into real applications.&lt;/p&gt;

&lt;h2 id=&quot;use-case-plugin-systems&quot;&gt;Use Case: Plugin Systems&lt;/h2&gt;

&lt;p&gt;Plugin systems benefit significantly from &lt;code&gt;Ruby::Box&lt;/code&gt;. Each plugin runs in its own isolated environment, preventing plugins from interfering with each other or the host application.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class PluginManager
  def initialize
    @plugins = {}
  end

  def load_plugin(name, path)
    box = Ruby::Box.new
    box.require(path)

    # Access the plugin class from within the box
    plugin_class = box.eval(&apos;Plugin&apos;)
    @plugins[name] = {
      box: box,
      instance: plugin_class.new
    }
  end

  def run(name, method, *args)
    plugin = @plugins[name]
    plugin[:instance].public_send(method, *args)
  end

  def unload(name)
    @plugins.delete(name)
    # Box becomes eligible for garbage collection
  end
end

# Usage
manager = PluginManager.new
manager.load_plugin(:markdown, &apos;./plugins/markdown_plugin&apos;)
manager.load_plugin(:syntax_highlight, &apos;./plugins/syntax_plugin&apos;)

# Each plugin has its own isolated environment
# If markdown_plugin patches String, syntax_plugin won&apos;t see it
manager.run(:markdown, :process, content)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This pattern ensures that a misbehaving plugin cannot corrupt the global namespace or break other plugins.&lt;/p&gt;

&lt;h2 id=&quot;use-case-multi-tenant-configuration&quot;&gt;Use Case: Multi-Tenant Configuration&lt;/h2&gt;

&lt;p&gt;Applications serving multiple tenants often need per-tenant configurations. &lt;code&gt;Ruby::Box&lt;/code&gt; provides clean isolation without complex scoping logic.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class TenantContext
  def initialize(tenant_id, config_path)
    @tenant_id = tenant_id
    @box = Ruby::Box.new
    @box.require(config_path)
  end

  def config
    @box.eval(&apos;TenantConfig&apos;)
  end

  def execute(code)
    @box.eval(code)
  end
end

# Each tenant gets isolated configuration
tenant_a = TenantContext.new(&apos;acme&apos;, &apos;./tenants/acme/config&apos;)
tenant_b = TenantContext.new(&apos;globex&apos;, &apos;./tenants/globex/config&apos;)

tenant_a.config.theme      # =&amp;gt; &quot;dark&quot;
tenant_b.config.theme      # =&amp;gt; &quot;light&quot;

# Global variables are isolated too
tenant_a.execute(&apos;$rate_limit = 100&apos;)
tenant_b.execute(&apos;$rate_limit = 500&apos;)

tenant_a.execute(&apos;$rate_limit&apos;)  # =&amp;gt; 100
tenant_b.execute(&apos;$rate_limit&apos;)  # =&amp;gt; 500
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;use-case-running-multiple-gem-versions&quot;&gt;Use Case: Running Multiple Gem Versions&lt;/h2&gt;

&lt;p&gt;During migrations, you might need to run two versions of the same gem simultaneously. &lt;code&gt;Ruby::Box&lt;/code&gt; makes this possible without separate processes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Load v1 API client in one box
v1_box = Ruby::Box.new
v1_box.eval &amp;lt;&amp;lt;~RUBY
  $LOAD_PATH.unshift(&apos;./vendor/api_client_v1/lib&apos;)
  require &apos;api_client&apos;
RUBY

# Load v2 API client in another box
v2_box = Ruby::Box.new
v2_box.eval &amp;lt;&amp;lt;~RUBY
  $LOAD_PATH.unshift(&apos;./vendor/api_client_v2/lib&apos;)
  require &apos;api_client&apos;
RUBY

# Compare behavior during migration
def compare_responses(endpoint, params)
  code = &quot;ApiClient.get(&apos;#{endpoint}&apos;, #{params.inspect})&quot;
  v1_response = v1_box.eval(code)
  v2_response = v2_box.eval(code)

  if v1_response != v2_response
    log_difference(endpoint, v1_response, v2_response)
  end

  v1_response  # Return v1 for now, switch to v2 when ready
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;use-case-isolated-monkey-patches-for-testing&quot;&gt;Use Case: Isolated Monkey Patches for Testing&lt;/h2&gt;

&lt;p&gt;Some tests require monkey patches that would pollute the global namespace. &lt;code&gt;Ruby::Box&lt;/code&gt; keeps these contained.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# test_helper.rb
def create_time_frozen_box(frozen_time)
  box = Ruby::Box.new
  box.eval &amp;lt;&amp;lt;~RUBY
    class Time
      def self.now
        Time.new(#{frozen_time.year}, #{frozen_time.month}, #{frozen_time.day})
      end
    end
  RUBY
  box
end

# In your test
def test_subscription_expiry
  box = create_time_frozen_box(Time.new(2026, 1, 1))

  # Load and test code within the frozen-time box
  box.eval &amp;lt;&amp;lt;~RUBY
    expiry_date = Time.new(2025, 12, 31)
    subscription = Subscription.new(expires_at: expiry_date)
    raise &quot;Expected expired&quot; unless subscription.expired?
  RUBY

  # Time.now is unchanged outside the box
  Time.now  # =&amp;gt; Current actual time
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;use-case-shadow-testing&quot;&gt;Use Case: Shadow Testing&lt;/h2&gt;

&lt;p&gt;Run new code paths alongside production code to compare results without affecting users. This pattern is useful for validating refactors or new implementations.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class ShadowRunner
  def initialize(production_box, shadow_box)
    @production = production_box
    @shadow = shadow_box
  end

  def run(method, *args)
    code = &quot;#{method}(#{args.map(&amp;amp;:inspect).join(&apos;, &apos;)})&quot;

    # Production path returns the result
    production_result = @production.eval(code)

    # Shadow path runs asynchronously, logs differences
    Thread.new do
      shadow_result = @shadow.eval(code)

      unless production_result == shadow_result
        Logger.warn(&quot;Shadow mismatch for #{method}&quot;,
          production: production_result,
          shadow: shadow_result
        )
      end
    end

    production_result
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;working-around-native-extension-issues&quot;&gt;Working Around Native Extension Issues&lt;/h2&gt;

&lt;p&gt;Native extensions may fail to install with &lt;code&gt;RUBY_BOX=1&lt;/code&gt; enabled. The solution is to separate installation from execution:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Gemfile installation without Boxing
bundle install

# Application execution with Boxing
RUBY_BOX=1 bundle exec ruby app.rb
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For CI/CD pipelines:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/test.yml
jobs:
  test:
    steps:
      - name: Install dependencies
        run: bundle install

      - name: Run tests with Ruby::Box
        run: RUBY_BOX=1 bundle exec rspec
        env:
          RUBY_BOX: &quot;1&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;working-around-activesupport-issues&quot;&gt;Working Around ActiveSupport Issues&lt;/h2&gt;

&lt;p&gt;Some ActiveSupport core extensions have compatibility issues. Load them in your main context before creating boxes:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# At application startup, before creating any boxes
require &apos;active_support/core_ext/string/inflections&apos;
require &apos;active_support/core_ext/hash/keys&apos;

# Now create boxes for isolated code
plugin_box = Ruby::Box.new
# Plugins can use the already-loaded extensions
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Alternatively, selectively load only what you need inside boxes:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;box = Ruby::Box.new
box.eval &amp;lt;&amp;lt;~RUBY
  # Load specific extensions that are known to work
  require &apos;active_support/core_ext/object/blank&apos;
RUBY
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;performance-considerations&quot;&gt;Performance Considerations&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; adds minimal overhead for most operations:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Method dispatch&lt;/strong&gt;: Slightly more indirection through separate method tables&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Object creation&lt;/strong&gt;: Unaffected, objects pass freely between boxes&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Memory&lt;/strong&gt;: Each box maintains its own class/module definitions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For performance-critical paths, cache class references:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class OptimizedPluginRunner
  def initialize(box)
    @box = box
    # Cache the class reference once
    @processor_class = box.eval(&apos;DataProcessor&apos;)
  end

  def process(data)
    # Use cached reference instead of evaluating each time
    @processor_class.new.process(data)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;when-to-use-rubybox&quot;&gt;When to Use &lt;code&gt;Ruby::Box&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Good candidates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Plugin or extension systems where isolation is critical&lt;/li&gt;
  &lt;li&gt;Multi-tenant applications with per-tenant customizations&lt;/li&gt;
  &lt;li&gt;Testing scenarios requiring invasive monkey patches&lt;/li&gt;
  &lt;li&gt;Gradual migration between gem versions&lt;/li&gt;
  &lt;li&gt;Applications loading third-party code that might conflict&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Poor candidates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Running untrusted or potentially malicious code (use OS-level sandboxing)&lt;/li&gt;
  &lt;li&gt;Production systems until the feature stabilizes&lt;/li&gt;
  &lt;li&gt;Applications heavily dependent on native extensions&lt;/li&gt;
  &lt;li&gt;Simple applications without isolation requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;migration-strategy&quot;&gt;Migration Strategy&lt;/h2&gt;

&lt;p&gt;If you’re considering &lt;code&gt;Ruby::Box&lt;/code&gt; for an existing application:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Test compatibility&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Run your test suite with Boxing enabled
RUBY_BOX=1 bundle exec rspec
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Identify issues&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Look for failures related to:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Shared global state across files&lt;/li&gt;
  &lt;li&gt;Assumptions about class modifications being visible everywhere&lt;/li&gt;
  &lt;li&gt;Native extension loading errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Refactor incrementally&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Start with isolated subsystems that don’t share state with the rest of your application. Move more code into boxes as you gain confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Monitor in staging&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run your staging environment with &lt;code&gt;RUBY_BOX=1&lt;/code&gt; before considering production deployment.&lt;/p&gt;

&lt;h2 id=&quot;whats-next-for-rubybox&quot;&gt;What’s Next for &lt;code&gt;Ruby::Box&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;The Ruby core team has discussed building a higher-level “packages” API on top of &lt;code&gt;Ruby::Box&lt;/code&gt;. This would provide more ergonomic ways to manage gem isolation without manual box management. Track progress in &lt;a href=&quot;https://bugs.ruby-lang.org/issues/21681&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby packages feature discussion (opens in new tab)&quot;&gt;Ruby Issue #21681&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; solves real problems around namespace pollution and gem conflicts. While still experimental, it’s worth exploring for applications where isolation matters. Start with non-critical paths, understand the limitations, and provide feedback to the Ruby core team as you experiment.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.ruby-lang.org/en/master/Ruby/Box.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby::Box official documentation (opens in new tab)&quot;&gt;Ruby::Box Official Documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/geeknees/ruby_box_shadow_universe&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby::Box shadow execution example repository (opens in new tab)&quot;&gt;Ruby::Box Shadow Execution Example&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://rubykaigi.org/2025/presentations/tagomoris.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;State of Namespace presentation at RubyKaigi 2025 (opens in new tab)&quot;&gt;RubyKaigi 2025: State of Namespace&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://bugs.ruby-lang.org/issues/21681&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby packages feature discussion (opens in new tab)&quot;&gt;Ruby Issue #21681: Packages API&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Thu, 15 Jan 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/ruby-4-ruby-box-practical-guide-part-2/</link>
        <guid isPermaLink="true">https://prateekcodes.com/ruby-4-ruby-box-practical-guide-part-2/</guid>
        
        <category>ruby</category>
        
        <category>ruby-box</category>
        
        <category>namespace</category>
        
        <category>isolation</category>
        
        <category>plugins</category>
        
        <category>multi-tenant</category>
        
        <category>ruby-4</category>
        
        
        <category>Ruby</category>
        
        <category>Ruby 4.0</category>
        
        <category>Isolation</category>
        
      </item>
    
      <item>
        <title>Ruby 4.0 Introduces Ruby::Box for In-Process Isolation (Part 1)</title>
        <description>&lt;p&gt;Ruby 4.0 introduces &lt;code&gt;Ruby::Box&lt;/code&gt;, a feature that provides isolated namespaces within a single Ruby process. This solves a long-standing problem: monkey patches and global modifications from one gem affecting all other code in your application.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-with-shared-namespaces&quot;&gt;The Problem with Shared Namespaces&lt;/h2&gt;

&lt;p&gt;When you load a gem that modifies core classes, those changes affect everything in your Ruby process:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Gem A adds a titleize method to String
class String
  def titleize
    split.map(&amp;amp;:capitalize).join(&apos; &apos;)
  end
end

# Now EVERY piece of code in your process sees this method
# Including Gem B, which might have its own expectations

&quot;hello world&quot;.titleize  # =&amp;gt; &quot;Hello World&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This becomes problematic when:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Two gems define conflicting methods on the same class&lt;/li&gt;
  &lt;li&gt;A gem’s monkey patch breaks another library’s assumptions&lt;/li&gt;
  &lt;li&gt;You want to test code in isolation from invasive patches&lt;/li&gt;
  &lt;li&gt;You need to run multiple versions of a gem simultaneously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before Ruby 4.0, the only solutions were separate Ruby processes (with IPC overhead) or containers (with even more overhead).&lt;/p&gt;

&lt;h2 id=&quot;ruby-40-enter-rubybox&quot;&gt;Ruby 4.0: Enter Ruby::Box&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; creates isolated spaces where code runs with its own class definitions, constants, and global variables. Changes made inside a box stay inside that box.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Enable with environment variable at startup
# RUBY_BOX=1 ruby my_script.rb

# Check if Boxing is available
Ruby::Box.enabled?  # =&amp;gt; true

# Create an isolated box
box = Ruby::Box.new

# Load code that patches String
box.eval &amp;lt;&amp;lt;~RUBY
  class String
    def shout
      upcase + &quot;!!!&quot;
    end
  end
RUBY

# The patch exists only inside the box
box.eval(&apos;&quot;hello&quot;.shout&apos;)  # =&amp;gt; &quot;HELLO!!!&quot;

# Outside the box, String is unchanged
&quot;hello&quot;.shout  # =&amp;gt; NoMethodError: undefined method `shout&apos;
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;understanding-box-types&quot;&gt;Understanding Box Types&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; operates with three types of boxes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root Box&lt;/strong&gt;: Contains all built-in Ruby classes and modules. This is established before any user code runs and serves as the template for other boxes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main Box&lt;/strong&gt;: Your application’s default execution context. It’s automatically created from the root box when the process starts. This is where your main script runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User Boxes&lt;/strong&gt;: Custom boxes you create with &lt;code&gt;Ruby::Box.new&lt;/code&gt;. Each is copied from the root box, giving it a clean slate of built-in classes without any modifications from the main box or other user boxes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Your script runs in the &quot;main&quot; box
Ruby::Box.current  # =&amp;gt; #&amp;lt;Ruby::Box main&amp;gt;

# Create isolated boxes
plugin_box = Ruby::Box.new
another_box = Ruby::Box.new

# Each box is independent
plugin_box.object_id != another_box.object_id  # =&amp;gt; true
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;the-rubybox-api&quot;&gt;The Ruby::Box API&lt;/h2&gt;

&lt;p&gt;The API is straightforward with just a few methods:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Creation
box = Ruby::Box.new

# Loading code
box.require(&apos;some_library&apos;)        # Respects box&apos;s $LOAD_PATH
box.require_relative(&apos;./my_file&apos;)  # Relative to current file
box.load(&apos;script.rb&apos;)              # Direct file execution

# Executing code
box.eval(&apos;1 + 1&apos;)                  # Execute Ruby code as string

# Inspection
Ruby::Box.current    # Returns the currently executing box
Ruby::Box.enabled?   # Check if Boxing is active
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;what-gets-isolated&quot;&gt;What Gets Isolated&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; isolates several aspects of the Ruby runtime:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classes and Constants&lt;/strong&gt;: Reopening a built-in class in one box doesn’t affect other boxes.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;box = Ruby::Box.new
box.eval &amp;lt;&amp;lt;~RUBY
  class Array
    def sum_squares
      map { |n| n ** 2 }.sum
    end
  end
RUBY

box.eval(&apos;[1, 2, 3].sum_squares&apos;)  # =&amp;gt; 14
[1, 2, 3].sum_squares              # =&amp;gt; NoMethodError
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Global Variables&lt;/strong&gt;: Changes to globals stay within the box.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;box = Ruby::Box.new
box.eval(&apos;$my_config = { debug: true }&apos;)

box.eval(&apos;$my_config&apos;)  # =&amp;gt; { debug: true }
$my_config              # =&amp;gt; nil
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Top-Level Methods&lt;/strong&gt;: Methods defined at the top level become private instance methods of &lt;code&gt;Object&lt;/code&gt; within that box only.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;box = Ruby::Box.new
box.eval &amp;lt;&amp;lt;~RUBY
  def helper_method
    &quot;I&apos;m only available in this box&quot;
  end
RUBY

box.eval(&apos;helper_method&apos;)  # =&amp;gt; &quot;I&apos;m only available in this box&quot;
helper_method              # =&amp;gt; NoMethodError
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;enabling-rubybox&quot;&gt;Enabling Ruby::Box&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; is disabled by default. Enable it by setting the &lt;code&gt;RUBY_BOX&lt;/code&gt; environment variable before the Ruby process starts:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;RUBY_BOX=1 ruby my_application.rb
&lt;/code&gt;&lt;/pre&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt;: Setting &lt;code&gt;RUBY_BOX&lt;/code&gt; after the process has started has no effect. The boxing infrastructure must be initialized during Ruby’s boot sequence, so the variable must be set before the Ruby process starts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# This check should be at the top of your application
unless Ruby::Box.enabled?
  warn &quot;Ruby::Box is not enabled. Start with RUBY_BOX=1&quot;
  exit 1
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;important-limitations&quot;&gt;Important Limitations&lt;/h2&gt;

&lt;p&gt;Before adopting &lt;code&gt;Ruby::Box&lt;/code&gt;, be aware of these constraints:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not a Security Sandbox&lt;/strong&gt;: &lt;code&gt;Ruby::Box&lt;/code&gt; provides namespace isolation, not security isolation. Code in a box can still access the filesystem, network, and system resources. Do not use it to run untrusted code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native Extensions&lt;/strong&gt;: Installing gems with native extensions may fail when &lt;code&gt;RUBY_BOX=1&lt;/code&gt; is set. The workaround is to install gems without the flag, then run your application with it enabled.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install gems normally
bundle install

# Run with Boxing enabled
RUBY_BOX=1 bundle exec ruby app.rb
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;ActiveSupport Compatibility&lt;/strong&gt;: Some parts of &lt;code&gt;active_support/core_ext&lt;/code&gt; have compatibility issues with &lt;code&gt;Ruby::Box&lt;/code&gt;. Load &lt;code&gt;ActiveSupport&lt;/code&gt; in your main context before creating boxes if needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experimental Status&lt;/strong&gt;: This feature is experimental in Ruby 4.0. Behavior may change in future versions. The Ruby core team recommends experimentation but advises caution in production environments.&lt;/p&gt;

&lt;h2 id=&quot;file-scope-execution&quot;&gt;File Scope Execution&lt;/h2&gt;

&lt;p&gt;One important detail: &lt;code&gt;Ruby::Box&lt;/code&gt; operates on a file-scope basis. Each &lt;code&gt;.rb&lt;/code&gt; file executes entirely within a single box. Once loaded, all methods and procs defined in that file operate within their originating box, regardless of where they’re called from.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# helper.rb
def process(data)
  # This method always runs in the box where helper.rb was loaded
  data.transform
end

# main.rb
box = Ruby::Box.new
box.require_relative(&apos;helper&apos;)

# Even when called from main, process() runs in box&apos;s context
box.eval(&apos;process(my_data)&apos;)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;Ruby::Box&lt;/code&gt; brings a long-requested capability to Ruby: proper namespace isolation without process boundaries. In &lt;a href=&quot;/ruby-4-ruby-box-practical-guide-part-2/&quot;&gt;Part 2&lt;/a&gt;, we’ll explore practical use cases including plugin systems, multi-tenant configurations, and strategies for gradual adoption.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.ruby-lang.org/en/master/Ruby/Box.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby::Box official documentation (opens in new tab)&quot;&gt;Ruby::Box Official Documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.ruby-lang.org/en/news/2025/12/25/ruby-4-0-0-released/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby 4.0.0 release announcement (opens in new tab)&quot;&gt;Ruby 4.0.0 Release Notes&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://dev.to/ko1/rubybox-digest-introduction-ruby-400-new-feature-3bch&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;ko1&apos;s Ruby::Box introduction on DEV.to (opens in new tab)&quot;&gt;Ruby::Box Introduction by ko1&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://rubyreferences.github.io/rubychanges/4.0.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Ruby 4.0 changes reference (opens in new tab)&quot;&gt;Ruby 4.0 Changes Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Wed, 14 Jan 2026 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/ruby-4-introduces-ruby-box-for-in-process-isolation-part-1/</link>
        <guid isPermaLink="true">https://prateekcodes.com/ruby-4-introduces-ruby-box-for-in-process-isolation-part-1/</guid>
        
        <category>ruby</category>
        
        <category>ruby-box</category>
        
        <category>namespace</category>
        
        <category>isolation</category>
        
        <category>monkey-patching</category>
        
        <category>ruby-4</category>
        
        
        <category>Ruby</category>
        
        <category>Ruby 4.0</category>
        
        <category>Isolation</category>
        
      </item>
    
      <item>
        <title>Rails 8.2 makes enqueue_after_transaction_commit the default</title>
        <description>&lt;p&gt;&lt;a href=&quot;/rails-72-enqueue-after-transaction-commit&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Blog post about Rails 7.2 enqueue_after_transaction_commit (opens in new tab)&quot;&gt;Rails 7.2&lt;/a&gt; introduced &lt;code&gt;enqueue_after_transaction_commit&lt;/code&gt; to prevent race conditions when jobs are enqueued inside database transactions. However, it required explicit opt-in. Rails 8.2 flips the default. Jobs are now automatically deferred until after the transaction commits.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-with-opt-in&quot;&gt;The Problem with Opt-In&lt;/h2&gt;

&lt;p&gt;With the opt-in approach in Rails 7.2, teams had to remember to enable the feature:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;config.active_job.enqueue_after_transaction_commit = :default
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Or configure it per-job:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class WelcomeEmailJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = :always
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This created inconsistency. Some jobs would be transaction-aware, others would not. The safer behavior required explicit action.&lt;/p&gt;

&lt;h2 id=&quot;rails-82-changes-the-default&quot;&gt;Rails 8.2 Changes the Default&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55788&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR making enqueue_after_transaction_commit the default (opens in new tab)&quot;&gt;PR #55788&lt;/a&gt; changes this. When you upgrade to Rails 8.2 and run &lt;code&gt;load_defaults &quot;8.2&quot;&lt;/code&gt;, jobs are automatically deferred until after the transaction commits.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;def create
  User.transaction do
    user = User.create!(params)
    WelcomeEmailJob.perform_later(user)  # Deferred until commit
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;No configuration needed. The job waits for the transaction to complete before being dispatched to the queue.&lt;/p&gt;

&lt;h2 id=&quot;opting-out&quot;&gt;Opting Out&lt;/h2&gt;

&lt;p&gt;If you need immediate enqueueing for backward compatibility or specific use cases, you have two options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;config.active_job.enqueue_after_transaction_commit = false
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Per-job configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class TimeStampedJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = false
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;why-the-global-config-was-restored&quot;&gt;Why the Global Config Was Restored&lt;/h2&gt;

&lt;p&gt;The global configuration option has an interesting history. It was deprecated and removed in Rails 8.1. The team initially wanted each job to declare its own preference. However, changing the default behavior without a global opt-out would break existing applications.&lt;/p&gt;

&lt;p&gt;The PR restored the global configuration specifically to allow apps upgrading to Rails 8.2 to maintain their existing behavior without modifying every job class.&lt;/p&gt;

&lt;h2 id=&quot;when-this-matters&quot;&gt;When This Matters&lt;/h2&gt;

&lt;p&gt;The new default primarily affects jobs enqueued to external queues like Redis (Sidekiq, Resque). If you use a database-backed queue like Solid Queue or GoodJob with the same database, your jobs are already part of the same transaction.&lt;/p&gt;

&lt;p&gt;Jobs that do not depend on transaction data can still be configured for immediate enqueueing if needed.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Rails 8.2 makes the safer behavior the default. Jobs enqueued inside transactions automatically wait for the commit, eliminating a common source of race conditions without requiring explicit configuration.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55788&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR making enqueue_after_transaction_commit the default (opens in new tab)&quot;&gt;Pull Request #55788&lt;/a&gt; making this the default&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/51426&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR introducing enqueue_after_transaction_commit (opens in new tab)&quot;&gt;Pull Request #51426&lt;/a&gt; introducing the feature in Rails 7.2&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/rails-72-enqueue-after-transaction-commit&quot; aria-label=&quot;Blog post about Rails 7.2 enqueue_after_transaction_commit&quot;&gt;Rails 7.2 enqueue_after_transaction_commit&lt;/a&gt; - detailed explanation of the feature&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Wed, 31 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-82-enqueue-after-transaction-commit-default/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-82-enqueue-after-transaction-commit-default/</guid>
        
        <category>rails-8-2</category>
        
        <category>active-job</category>
        
        <category>transactions</category>
        
        <category>background-jobs</category>
        
        
        <category>Rails</category>
        
        <category>Rails 8.2</category>
        
        <category>Active Job</category>
        
      </item>
    
      <item>
        <title>Rails 7.2 adds enqueue_after_transaction_commit to prevent job race conditions</title>
        <description>&lt;p&gt;Scheduling background jobs inside database transactions is a common anti-pattern which is a source of several production bugs in Rails applications. The job can execute before the transaction commits, leading to &lt;code&gt;RecordNotFound&lt;/code&gt; or &lt;code&gt;ActiveJob::DeserializationError&lt;/code&gt; because the data it needs does not exist yet. Or worse, the job could run assuming the txn would commit, but it rolls back at a later stage. We don’t need that kind of optimism.&lt;/p&gt;

&lt;p&gt;Rails 7.2 addresses this with &lt;code&gt;enqueue_after_transaction_commit&lt;/code&gt;, which automatically defers job enqueueing until the transaction completes.&lt;/p&gt;

&lt;h2 id=&quot;before&quot;&gt;Before&lt;/h2&gt;

&lt;p&gt;Consider a typical pattern where you create a user and send a welcome email:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class UsersController &amp;lt; ApplicationController
  def create
    User.transaction do
      @user = User.create!(user_params)
      WelcomeEmailJob.perform_later(@user)
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This code works fine in development where your job queue is slow and transactions commit quickly. In production, with a fast Redis-backed queue like Sidekiq and a busy database, the job can start executing before the transaction commits:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Timeline:
1. Transaction begins
2. User INSERT executes (not committed yet)
3. Job enqueued to Redis
4. Sidekiq picks up job immediately
5. Job tries to find User -&amp;gt; RecordNotFound!
6. Transaction commits (too late)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The same problem occurs with &lt;code&gt;after_create&lt;/code&gt; callbacks in models:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Project &amp;lt; ApplicationRecord
  after_create -&amp;gt; { NotifyParticipantsJob.perform_later(self) }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;the-workaround&quot;&gt;The Workaround&lt;/h3&gt;

&lt;p&gt;The standard fix was to use &lt;code&gt;after_commit&lt;/code&gt; callbacks instead:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Project &amp;lt; ApplicationRecord
  after_create_commit -&amp;gt; { NotifyParticipantsJob.perform_later(self) }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Or wrap job scheduling in explicit &lt;code&gt;after_commit&lt;/code&gt; blocks:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class UsersController &amp;lt; ApplicationController
  def create
    User.transaction do
      @user = User.create!(user_params)

      ActiveRecord::Base.connection.after_transaction_commit do
        WelcomeEmailJob.perform_later(@user)
      end
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This worked but had problems:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Easy to forget&lt;/strong&gt;: Using &lt;code&gt;after_create&lt;/code&gt; instead of &lt;code&gt;after_create_commit&lt;/code&gt; is a common mistake&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Scattered logic&lt;/strong&gt;: Job scheduling gets coupled to model callbacks instead of staying in controllers or service objects&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Verbose&lt;/strong&gt;: Wrapping every &lt;code&gt;perform_later&lt;/code&gt; call in &lt;code&gt;after_commit&lt;/code&gt; blocks adds boilerplate&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Testing friction&lt;/strong&gt;: Transaction callbacks behave differently in test environments using database cleaner with transactions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/Envek/after_commit_everywhere&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;after_commit_everywhere gem on GitHub (opens in new tab)&quot;&gt;after_commit_everywhere&lt;/a&gt; gem became popular specifically to address this problem. It lets you use &lt;code&gt;after_commit&lt;/code&gt; callbacks anywhere in your application, not just in ActiveRecord models:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class UserRegistrationService
  include AfterCommitEverywhere

  def call(params)
    User.transaction do
      user = User.create!(params)

      after_commit do
        WelcomeEmailJob.perform_later(user)
      end
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The gem hooks into ActiveRecord’s transaction lifecycle and ensures callbacks only fire after the outermost transaction commits. It handled nested transactions correctly and became a go-to solution for service objects that needed transaction-safe job scheduling.&lt;/p&gt;

&lt;p&gt;Some teams built their own lightweight wrappers instead:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Custom AsyncRecord class that hooks into transaction callbacks
class AsyncRecord
  def initialize(&amp;amp;block)
    @callback = block
  end

  def has_transactional_callbacks?
    true
  end

  def committed!(*)
    @callback.call
  end

  def rolledback!(*)
    # Do nothing if transaction rolled back
  end
end

# Usage
User.transaction do
  user = User.create!(params)
  record = AsyncRecord.new { WelcomeEmailJob.perform_later(user) }
  user.class.connection.add_transaction_record(record)
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Both approaches worked, but required teams to remember to use them consistently.&lt;/p&gt;

&lt;h2 id=&quot;rails-72&quot;&gt;Rails 7.2&lt;/h2&gt;

&lt;p&gt;Rails 7.2 makes Active Job transaction-aware. Jobs are automatically deferred until the transaction commits, and dropped if it rolls back.&lt;/p&gt;

&lt;p&gt;Enable it globally in your application:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# config/application.rb
config.active_job.enqueue_after_transaction_commit = :default
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now the original code just works:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class UsersController &amp;lt; ApplicationController
  def create
    User.transaction do
      @user = User.create!(user_params)
      WelcomeEmailJob.perform_later(@user)  # Deferred until commit
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The job only gets enqueued after the transaction successfully commits. If the transaction rolls back, the job is never enqueued.&lt;/p&gt;

&lt;h3 id=&quot;configuration-options&quot;&gt;Configuration Options&lt;/h3&gt;

&lt;p&gt;You can control this behavior at three levels:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# config/application.rb
config.active_job.enqueue_after_transaction_commit = :default
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Per-job configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class WelcomeEmailJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = :always
end

class AuditLogJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = :never  # Queue immediately
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The available values are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;:default&lt;/code&gt; - Let the queue adapter decide the behavior&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;:always&lt;/code&gt; - Always defer until transaction commits&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;:never&lt;/code&gt; - Queue immediately (pre-7.2 behavior)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;checking-enqueue-status&quot;&gt;Checking Enqueue Status&lt;/h3&gt;

&lt;p&gt;Since &lt;code&gt;perform_later&lt;/code&gt; returns immediately even when the job is deferred, you can check if it was actually enqueued:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;User.transaction do
  user = User.create!(user_params)
  job = WelcomeEmailJob.perform_later(user)

  # job.successfully_enqueued? returns false here (still deferred)
end

# After transaction commits, job.successfully_enqueued? returns true
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;model-callbacks-simplified&quot;&gt;Model Callbacks Simplified&lt;/h3&gt;

&lt;p&gt;You can now safely use &lt;code&gt;after_create&lt;/code&gt; for job scheduling without worrying about transaction timing:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Project &amp;lt; ApplicationRecord
  # This is now safe with enqueue_after_transaction_commit enabled
  after_create -&amp;gt; { NotifyParticipantsJob.perform_later(self) }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The job automatically waits for any enclosing transaction to complete.&lt;/p&gt;

&lt;h2 id=&quot;when-to-disable&quot;&gt;When to Disable&lt;/h2&gt;

&lt;p&gt;Some scenarios require immediate enqueueing:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Database-backed queues&lt;/strong&gt;: If you use Solid Queue, GoodJob, or Delayed Job with the same database, jobs are part of the same transaction and this deferral is unnecessary&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Fire-and-forget jobs&lt;/strong&gt;: Jobs that do not depend on the transaction data can run immediately&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Time-sensitive operations&lt;/strong&gt;: If you need the job queued at a specific moment regardless of transaction state&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class TimeStampedJob &amp;lt; ApplicationJob
  self.enqueue_after_transaction_commit = :never

  def perform
    # This job needs to capture the exact enqueue time
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;update-rails-82-makes-this-the-default&quot;&gt;Update: Rails 8.2 Makes This the Default&lt;/h2&gt;

&lt;p&gt;Rails 8.2 makes &lt;code&gt;enqueue_after_transaction_commit&lt;/code&gt; the default behavior. Jobs are now automatically deferred until after the transaction commits without requiring explicit configuration.&lt;/p&gt;

&lt;p&gt;See &lt;a href=&quot;/rails-82-enqueue-after-transaction-commit-default&quot; aria-label=&quot;Blog post about Rails 8.2 enqueue_after_transaction_commit default&quot;&gt;Rails 8.2 makes enqueue_after_transaction_commit the default&lt;/a&gt; for details on the change, opting out, and the deprecation history.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;enqueue_after_transaction_commit&lt;/code&gt; eliminates a common source of race conditions in Rails applications. Instead of remembering to use &lt;code&gt;after_commit&lt;/code&gt; callbacks or building custom workarounds, jobs are automatically deferred until transactions complete.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/51426&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR introducing enqueue_after_transaction_commit (opens in new tab)&quot;&gt;Pull Request #51426&lt;/a&gt; introducing the feature&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/55788&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR making enqueue_after_transaction_commit the default in Rails 8.2 (opens in new tab)&quot;&gt;Pull Request #55788&lt;/a&gt; making this the default in Rails 8.2&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/issues/26045&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;DHH&apos;s original issue about job scheduling in transactions (opens in new tab)&quot;&gt;Original Issue #26045&lt;/a&gt; by DHH describing the problem&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://guides.rubyonrails.org/active_job_basics.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails Active Job documentation (opens in new tab)&quot;&gt;Active Job Basics Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Tue, 30 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-72-enqueue-after-transaction-commit/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-72-enqueue-after-transaction-commit/</guid>
        
        <category>rails-7-2</category>
        
        <category>active-job</category>
        
        <category>transactions</category>
        
        <category>background-jobs</category>
        
        <category>sidekiq</category>
        
        
        <category>Rails</category>
        
        <category>Rails 7.2</category>
        
        <category>Active Job</category>
        
      </item>
    
      <item>
        <title>Rails 8.2 introduces Rails.app.creds for unified credential management</title>
        <description>&lt;p&gt;Applications often store secrets in both environment variables and encrypted credential files. Migrating between these storage methods or using both simultaneously has traditionally required code changes. Rails 8.2 solves this with &lt;code&gt;Rails.app.creds&lt;/code&gt;, a unified API that checks ENV first, then falls back to encrypted credentials.&lt;/p&gt;

&lt;h2 id=&quot;before&quot;&gt;Before&lt;/h2&gt;

&lt;p&gt;Managing credentials from multiple sources meant mixing different APIs:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class StripeService
  def initialize
    # Check ENV first, fallback to credentials
    @api_key = ENV[&quot;STRIPE_API_KEY&quot;] || Rails.application.credentials.dig(:stripe, :api_key)
    @webhook_secret = ENV.fetch(&quot;STRIPE_WEBHOOK_SECRET&quot;) {
      Rails.application.credentials.stripe&amp;amp;.webhook_secret
    }

    raise &quot;Missing Stripe API key!&quot; unless @api_key
  end
end

class DatabaseConfig
  def connection_url
    # Different syntax for each source
    ENV[&quot;DATABASE_URL&quot;] || Rails.application.credentials.database_url
  end

  def redis_url
    ENV.fetch(&quot;REDIS_URL&quot;, Rails.application.credentials.dig(:redis, :url) || &quot;redis://localhost:6379&quot;)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This approach has several problems:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Inconsistent APIs between &lt;code&gt;ENV.fetch()&lt;/code&gt; and &lt;code&gt;credentials.dig()&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Manual fallback logic scattered throughout the codebase&lt;/li&gt;
  &lt;li&gt;Code changes required when moving secrets between storage methods&lt;/li&gt;
  &lt;li&gt;Easy to forget nil checks on nested credentials&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;rails-82&quot;&gt;Rails 8.2&lt;/h2&gt;

&lt;p&gt;The new &lt;code&gt;Rails.app.creds&lt;/code&gt; provides a consistent interface:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class StripeService
  def initialize
    @api_key = Rails.app.creds.require(:stripe_api_key)
    @webhook_secret = Rails.app.creds.require(:stripe_webhook_secret)
  end
end

class DatabaseConfig
  def connection_url
    Rails.app.creds.require(:database_url)
  end

  def redis_url
    Rails.app.creds.option(:redis_url, default: &quot;redis://localhost:6379&quot;)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;require&lt;/code&gt; method mandates a value exists and raises &lt;code&gt;KeyError&lt;/code&gt; if missing from both ENV and encrypted credentials. The &lt;code&gt;option&lt;/code&gt; method returns &lt;code&gt;nil&lt;/code&gt; or a default value gracefully.&lt;/p&gt;

&lt;h2 id=&quot;nested-keys&quot;&gt;Nested Keys&lt;/h2&gt;

&lt;p&gt;For nested credentials, pass multiple keys. Rails automatically converts them to the appropriate format for each source:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Checks ENV[&quot;AWS__ACCESS_KEY_ID&quot;] first, then credentials.dig(:aws, :access_key_id)
Rails.app.creds.require(:aws, :access_key_id)

# Multi-level nesting
# ENV[&quot;REDIS__CACHE__TTL&quot;] || credentials.dig(:redis, :cache, :ttl)
Rails.app.creds.option(:redis, :cache, :ttl, default: 3600)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The ENV lookup uses double underscores (&lt;code&gt;__&lt;/code&gt;) as separators for nested keys:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;:database_url&lt;/code&gt; → &lt;code&gt;ENV[&quot;DATABASE_URL&quot;]&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;[:aws, :region]&lt;/code&gt; → &lt;code&gt;ENV[&quot;AWS__REGION&quot;]&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;[:redis, :cache, :ttl]&lt;/code&gt; → &lt;code&gt;ENV[&quot;REDIS__CACHE__TTL&quot;]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;dynamic-defaults&quot;&gt;Dynamic Defaults&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;option&lt;/code&gt; method accepts callable defaults, evaluated only when needed:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;Rails.app.creds.option(:cache_ttl, default: -&amp;gt; { 1.hour })
Rails.app.creds.option(:max_connections, default: -&amp;gt; { calculate_pool_size })
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;env-only-access&quot;&gt;ENV-Only Access&lt;/h2&gt;

&lt;p&gt;Access environment variables directly using the same API via &lt;code&gt;Rails.app.envs&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Only checks ENV, no encrypted credentials fallback
Rails.app.envs.require(:port)
Rails.app.envs.option(:log_level, default: &quot;info&quot;)
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;custom-credential-sources&quot;&gt;Custom Credential Sources&lt;/h2&gt;

&lt;p&gt;Under the hood, &lt;code&gt;Rails.app.creds&lt;/code&gt; is powered by &lt;code&gt;ActiveSupport::CombinedConfiguration&lt;/code&gt;, which checks multiple credential sources (called backends) in order. By default, it checks ENV first, then encrypted credentials. You can customize this chain to include external secret managers:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  Rails.app.envs,                   # Check ENV first
  VaultConfiguration.new,           # Then HashiCorp Vault
  OnePasswordConfiguration.new,     # Then 1Password
  Rails.app.credentials             # Finally, encrypted credentials
)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Each credential source needs to implement &lt;code&gt;require&lt;/code&gt; and &lt;code&gt;option&lt;/code&gt; methods matching the API.&lt;/p&gt;

&lt;h2 id=&quot;railsapp-alias&quot;&gt;Rails.app Alias&lt;/h2&gt;

&lt;p&gt;This feature comes alongside a new &lt;code&gt;Rails.app&lt;/code&gt; alias for &lt;code&gt;Rails.application&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# Before
Rails.application.credentials.aws.access_key_id

# After
Rails.app.credentials.aws.access_key_id
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The shorter alias makes chained method calls more pleasant to read and write.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Rails.app.creds&lt;/code&gt; eliminates the friction of managing credentials across multiple sources. Secrets can move between ENV and encrypted files without touching application code.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/56404&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 56404 add Rails.app.creds (opens in new tab)&quot;&gt;PR #56404&lt;/a&gt; - Add Rails.app.creds for combined credentials lookup&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rails/rails/pull/56403&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;Rails PR 56403 add Rails.app alias (opens in new tab)&quot;&gt;PR #56403&lt;/a&gt; - Add Rails.app alias for Rails.application&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Mon, 29 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/rails-8-2-combined-credentials-rails-app-creds/</link>
        <guid isPermaLink="true">https://prateekcodes.com/rails-8-2-combined-credentials-rails-app-creds/</guid>
        
        <category>rails-8</category>
        
        <category>credentials</category>
        
        <category>configuration</category>
        
        <category>environment-variables</category>
        
        <category>secrets-management</category>
        
        
        <category>Rails</category>
        
        <category>Rails 8</category>
        
        <category>Configuration</category>
        
      </item>
    
      <item>
        <title>Understanding PostgreSQL Checkpoints: From WAL to Disk</title>
        <description>&lt;p&gt;PostgreSQL relies on checkpoints to ensure data durability while maintaining performance. Understanding how checkpoints work and their relationship with Write-Ahead Logging is essential for database performance tuning and troubleshooting.&lt;/p&gt;

&lt;p&gt;This post builds on fundamental concepts covered in our PostgreSQL internals series:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/postgres-fundamentals-memory-vs-disk-part-1&quot;&gt;Part 1: Memory vs Disk Performance&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/postgres-fundamentals-database-storage-part-2&quot;&gt;Part 2: How Databases Store Data&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/postgres-fundamentals-transactions-part-3&quot;&gt;Part 3: Transactions and ACID&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/postgres-fundamentals-performance-patterns-part-4&quot;&gt;Part 4: Performance Patterns&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/postgres-fundamentals-wal-deep-dive-part-5&quot;&gt;Part 5: Write-Ahead Logging Deep Dive&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/postgres-fundamentals-monitoring-administration-part-6&quot;&gt;Part 6: Monitoring and Administration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;write-ahead-logging-the-foundation&quot;&gt;Write-Ahead Logging: The Foundation&lt;/h2&gt;

&lt;p&gt;Checkpoints work hand-in-hand with &lt;a href=&quot;/postgres-fundamentals-wal-deep-dive-part-5&quot;&gt;Write-Ahead Logging (WAL)&lt;/a&gt;. When PostgreSQL modifies data, changes are written to WAL files first (sequential, fast) before updating data pages in memory. Modified pages (called “dirty pages”) accumulate in &lt;code&gt;shared_buffers&lt;/code&gt;, and eventually these changes need to be written to the actual data files. That’s where checkpoints come in.&lt;/p&gt;

&lt;h2 id=&quot;what-happens-during-a-checkpoint&quot;&gt;What Happens During a Checkpoint&lt;/h2&gt;

&lt;p&gt;A checkpoint is PostgreSQL’s process of writing all dirty pages from shared buffers to disk. It creates a known recovery point and ensures data durability.&lt;/p&gt;

&lt;h3 id=&quot;the-checkpoint-process&quot;&gt;The Checkpoint Process&lt;/h3&gt;

&lt;p&gt;When a checkpoint occurs:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Checkpoint starts&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;PostgreSQL marks the current WAL position as the checkpoint location&lt;/li&gt;
      &lt;li&gt;This position is the recovery starting point if a crash occurs&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Dirty pages are written&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;All modified data pages in &lt;code&gt;shared_buffers&lt;/code&gt; are flushed to disk&lt;/li&gt;
      &lt;li&gt;This happens gradually to avoid I/O spikes (controlled by &lt;code&gt;checkpoint_completion_target&lt;/code&gt;)&lt;/li&gt;
      &lt;li&gt;Pages are written in order to minimize random I/O&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Checkpoint completes&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;A checkpoint record is written to WAL&lt;/li&gt;
      &lt;li&gt;The &lt;code&gt;pg_control&lt;/code&gt; file is updated with the new checkpoint location&lt;/li&gt;
      &lt;li&gt;Old WAL files (before the checkpoint) can now be recycled or archived&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;what-triggers-a-checkpoint&quot;&gt;What Triggers a Checkpoint?&lt;/h3&gt;

&lt;p&gt;PostgreSQL creates checkpoints based on:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Time&lt;/strong&gt;: &lt;code&gt;checkpoint_timeout&lt;/code&gt; parameter (default: 5 minutes)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;WAL volume&lt;/strong&gt;: &lt;code&gt;max_wal_size&lt;/code&gt; parameter (default: 1GB)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Manual trigger&lt;/strong&gt;: &lt;code&gt;CHECKPOINT&lt;/code&gt; command&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Shutdown&lt;/strong&gt;: Always creates a checkpoint during clean shutdown&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Force an immediate checkpoint
CHECKPOINT;
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;checkpoint-impact-on-performance&quot;&gt;Checkpoint Impact on Performance&lt;/h3&gt;

&lt;p&gt;Checkpoints involve heavy I/O (writing potentially gigabytes of dirty pages), which can cause temporary performance degradation. Understanding &lt;a href=&quot;/postgres-fundamentals-performance-patterns-part-4&quot;&gt;performance trade-offs&lt;/a&gt; helps you balance durability with speed. PostgreSQL spreads checkpoint writes over time using &lt;code&gt;checkpoint_completion_target&lt;/code&gt; (default: 0.9) to minimize I/O spikes.&lt;/p&gt;

&lt;h2 id=&quot;monitoring-checkpoint-activity&quot;&gt;Monitoring Checkpoint Activity&lt;/h2&gt;

&lt;p&gt;For detailed monitoring techniques, see &lt;a href=&quot;/postgres-fundamentals-monitoring-administration-part-6&quot;&gt;Part 6: Monitoring and Administration&lt;/a&gt;. Key metrics to track:&lt;/p&gt;

&lt;h3 id=&quot;check-checkpoint-statistics&quot;&gt;Check Checkpoint Statistics&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- PostgreSQL 17+
SELECT * FROM pg_stat_checkpointer;

-- PostgreSQL 16 and earlier
SELECT * FROM pg_stat_bgwriter;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;What to look for:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;High &lt;code&gt;checkpoints_req&lt;/code&gt; (or &lt;code&gt;num_requested&lt;/code&gt; in v17+) means checkpoints are happening too frequently&lt;/li&gt;
  &lt;li&gt;Large &lt;code&gt;checkpoint_write_time&lt;/code&gt; (or &lt;code&gt;write_time&lt;/code&gt; in v17+) indicates heavy I/O load&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;monitor-wal-generation-rate&quot;&gt;Monitor WAL Generation Rate&lt;/h3&gt;

&lt;p&gt;High WAL generation can trigger frequent checkpoints. See &lt;a href=&quot;/postgres-fundamentals-monitoring-administration-part-6#pg_stat_wal-wal-activity&quot;&gt;Part 6&lt;/a&gt; for detailed WAL monitoring queries and interpretation.&lt;/p&gt;

&lt;h2 id=&quot;tuning-checkpoint-behavior&quot;&gt;Tuning Checkpoint Behavior&lt;/h2&gt;

&lt;p&gt;Key parameters to adjust (see &lt;a href=&quot;/postgres-fundamentals-performance-patterns-part-4#checkpoint-trade-off-alert&quot;&gt;Part 4: Performance Patterns&lt;/a&gt; for trade-offs):&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;checkpoint_timeout = 15min          # Default: 5min
max_wal_size = 4GB                  # Default: 1GB
checkpoint_completion_target = 0.9  # Default: 0.9
log_checkpoints = on
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Guidelines:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Increase &lt;code&gt;max_wal_size&lt;/code&gt; if &lt;code&gt;checkpoints_req&lt;/code&gt; is high&lt;/li&gt;
  &lt;li&gt;Increase &lt;code&gt;checkpoint_timeout&lt;/code&gt; for write-heavy workloads&lt;/li&gt;
  &lt;li&gt;Keep &lt;code&gt;checkpoint_completion_target&lt;/code&gt; at 0.9 to avoid I/O spikes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For broader PostgreSQL performance optimization, see &lt;a href=&quot;/postgresql-query-optimization-guide&quot;&gt;PostgreSQL query optimization guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Checkpoints are PostgreSQL’s mechanism for persisting in-memory changes to disk, creating recovery points, and managing WAL files. They balance performance with durability by batching writes and spreading I/O over time. Watch for frequent requested checkpoints and long write times as signals for tuning opportunities.&lt;/p&gt;

&lt;p&gt;For deeper understanding, explore the &lt;a href=&quot;/postgres-fundamentals-memory-vs-disk-part-1&quot;&gt;PostgreSQL internals series&lt;/a&gt;, or dive into &lt;a href=&quot;/postgresql-explain-analyze-deep-dive&quot;&gt;PostgreSQL EXPLAIN ANALYZE&lt;/a&gt; for query optimization.&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/wal-configuration.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;PostgreSQL WAL Configuration documentation (opens in new tab)&quot;&gt;PostgreSQL Documentation: WAL Configuration&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/wal-intro.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;PostgreSQL WAL introduction documentation (opens in new tab)&quot;&gt;PostgreSQL Documentation: Reliability and the Write-Ahead Log&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-BGWRITER-VIEW&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;PostgreSQL pg_stat_bgwriter documentation (opens in new tab)&quot;&gt;PostgreSQL Documentation: pg_stat_bgwriter&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Fri, 17 Oct 2025 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/understanding-postgres-checkpoints/</link>
        <guid isPermaLink="true">https://prateekcodes.com/understanding-postgres-checkpoints/</guid>
        
        <category>postgres</category>
        
        <category>wal</category>
        
        <category>checkpoints</category>
        
        <category>performance</category>
        
        <category>database-internals</category>
        
        
        <category>PostgreSQL</category>
        
        <category>Database</category>
        
      </item>
    
      <item>
        <title>PostgreSQL Fundamentals: Monitoring and Administration Tools (Part 6)</title>
        <description>&lt;p&gt;In &lt;a href=&quot;/postgres-fundamentals-wal-deep-dive-part-5&quot;&gt;Part 5&lt;/a&gt;, we learned how Write-Ahead Logging works internally. Now let’s explore the tools PostgreSQL provides for monitoring and administering these systems.&lt;/p&gt;

&lt;p&gt;This is Part 6 (final part) of a series on PostgreSQL internals.&lt;/p&gt;

&lt;h2 id=&quot;system-views-your-window-into-postgresql&quot;&gt;System Views: Your Window Into PostgreSQL&lt;/h2&gt;

&lt;p&gt;PostgreSQL exposes extensive information through system views. These views are your primary tool for understanding what’s happening inside the database.&lt;/p&gt;

&lt;h3 id=&quot;pg_stat_checkpointer-checkpoint-statistics-postgresql-17&quot;&gt;pg_stat_checkpointer: Checkpoint Statistics (PostgreSQL 17+)&lt;/h3&gt;

&lt;p&gt;The most important view for checkpoint monitoring:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT * FROM pg_stat_checkpointer;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Key columns:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    num_timed,              -- Scheduled checkpoints (time-based)
    num_requested,          -- Requested checkpoints (WAL-based or manual)
    write_time,             -- Milliseconds spent writing files
    sync_time,              -- Milliseconds spent syncing files
    buffers_written,        -- Buffers written during checkpoints
    stats_reset            -- When stats were last reset
FROM pg_stat_checkpointer;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: Before PostgreSQL 17, checkpoint statistics were in &lt;code&gt;pg_stat_bgwriter&lt;/code&gt; with column names &lt;code&gt;checkpoints_timed&lt;/code&gt;, &lt;code&gt;checkpoints_req&lt;/code&gt;, &lt;code&gt;checkpoint_write_time&lt;/code&gt;, &lt;code&gt;checkpoint_sync_time&lt;/code&gt;, and &lt;code&gt;buffers_checkpoint&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;interpreting-pg_stat_checkpointer&quot;&gt;Interpreting pg_stat_checkpointer&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Example output:
num_timed:       1250    -- Mostly time-based (good)
num_requested:   45      -- Few requested (good)
write_time:      450000  -- 450 seconds total writing
sync_time:       2500    -- 2.5 seconds total syncing
buffers_written: 500000  -- 500k buffers written at checkpoints
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;What to look for:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;High num_requested&lt;/strong&gt;: Checkpoints happening too frequently
    &lt;ul&gt;
      &lt;li&gt;Solution: Increase &lt;code&gt;max_wal_size&lt;/code&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;High write_time&lt;/strong&gt;: Checkpoint I/O is slow
    &lt;ul&gt;
      &lt;li&gt;Solution: Increase &lt;code&gt;checkpoint_completion_target&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;Or: Improve disk I/O performance&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;High buffers_written relative to checkpoint frequency&lt;/strong&gt;: Large checkpoints
    &lt;ul&gt;
      &lt;li&gt;Solution: More frequent checkpoints or increase &lt;code&gt;shared_buffers&lt;/code&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;pg_stat_wal-wal-activity&quot;&gt;pg_stat_wal: WAL Activity&lt;/h3&gt;

&lt;p&gt;Monitor WAL generation and flush activity:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    wal_records,        -- Total WAL records generated
    wal_fpi,           -- Full page images written
    wal_bytes,         -- Total bytes written to WAL
    wal_buffers_full,  -- Times WAL buffer was full
    wal_write,         -- Number of WAL writes
    wal_sync,          -- Number of WAL syncs (fsync)
    wal_write_time,    -- Time spent writing WAL (ms)
    wal_sync_time,     -- Time spent syncing WAL (ms)
    stats_reset
FROM pg_stat_wal;
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;calculating-wal-generation-rate&quot;&gt;Calculating WAL Generation Rate&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Record current WAL stats
CREATE TEMP TABLE wal_baseline AS
SELECT
    now() AS measured_at,
    pg_current_wal_lsn() AS wal_lsn,
    wal_bytes
FROM pg_stat_wal;

-- Wait 60 seconds...
SELECT pg_sleep(60);

-- Calculate rate
SELECT
    pg_size_pretty(
        w.wal_bytes - b.wal_bytes
    ) AS wal_generated,
    EXTRACT(EPOCH FROM (now() - b.measured_at)) AS seconds,
    pg_size_pretty(
        (w.wal_bytes - b.wal_bytes) /
        EXTRACT(EPOCH FROM (now() - b.measured_at))
    ) || &apos;/s&apos; AS wal_rate
FROM pg_stat_wal w, wal_baseline b;

-- Example result:
-- wal_generated: 25 MB
-- seconds: 60
-- wal_rate: 427 kB/s
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;pg_stat_database-database-level-stats&quot;&gt;pg_stat_database: Database-Level Stats&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    datname,
    xact_commit,           -- Transactions committed
    xact_rollback,         -- Transactions rolled back
    blks_read,            -- Disk blocks read
    blks_hit,             -- Disk blocks found in cache
    tup_inserted,         -- Rows inserted
    tup_updated,          -- Rows updated
    tup_deleted,          -- Rows deleted
    temp_files,           -- Temp files created
    temp_bytes            -- Temp file bytes
FROM pg_stat_database
WHERE datname = current_database();
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Calculate cache hit ratio:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    datname,
    round(
        100.0 * blks_hit / nullif(blks_hit + blks_read, 0),
        2
    ) AS cache_hit_ratio
FROM pg_stat_database
WHERE datname = current_database();

-- Healthy databases: 95%+
-- Low ratio: Need more shared_buffers or working set too large
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;pg_stat_statements-query-level-wal-generation&quot;&gt;pg_stat_statements: Query-Level WAL Generation&lt;/h3&gt;

&lt;p&gt;Track which queries generate the most WAL:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Enable extension
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Top WAL generators (PostgreSQL 13+)
SELECT
    substring(query, 1, 60) AS query_preview,
    calls,
    pg_size_pretty(wal_bytes) AS wal_generated,
    pg_size_pretty(wal_bytes / calls) AS wal_per_call,
    round(100.0 * wal_bytes / sum(wal_bytes) OVER (), 2) AS wal_percent
FROM pg_stat_statements
ORDER BY wal_bytes DESC
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once you identify high WAL-generating queries, you can optimize write operations: batch INSERT/UPDATE operations, use &lt;code&gt;COPY&lt;/code&gt; for bulk loads, and consider whether all indexes are necessary.&lt;/p&gt;

&lt;h3 id=&quot;pg_stat_activity-live-connections&quot;&gt;pg_stat_activity: Live Connections&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    pid,
    usename,
    application_name,
    state,
    query_start,
    state_change,
    wait_event_type,
    wait_event,
    substring(query, 1, 50) AS query_preview
FROM pg_stat_activity
WHERE state != &apos;idle&apos;
ORDER BY query_start;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Find long-running queries:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT
    pid,
    now() - query_start AS duration,
    query
FROM pg_stat_activity
WHERE state = &apos;active&apos;
  AND now() - query_start &amp;gt; interval &apos;5 minutes&apos;
ORDER BY duration DESC;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For long-running queries, you can:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Analyze execution plans&lt;/strong&gt;: Use &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; to understand why queries are slow. See &lt;a href=&quot;/postgresql-explain-analyze-deep-dive&quot;&gt;PostgreSQL EXPLAIN ANALYZE Deep Dive&lt;/a&gt; for detailed analysis techniques.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Offload reads to replicas&lt;/strong&gt;: Move long-running SELECT queries to read replicas to reduce contention on the primary database. See &lt;a href=&quot;/rails-read-replicas-part-1-understanding-the-basics&quot;&gt;Rails Read Replicas Part 1&lt;/a&gt; for implementation patterns.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;postgresql-logs-checkpoint-and-wal-messages&quot;&gt;PostgreSQL Logs: Checkpoint and WAL Messages&lt;/h2&gt;

&lt;p&gt;Enable detailed logging in &lt;code&gt;postgresql.conf&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;# Log checkpoints
log_checkpoints = on

# Log long-running statements
log_min_duration_statement = 1000  # Log queries &amp;gt; 1 second

# Log connections and disconnections
log_connections = on
log_disconnections = on

# Set log destination
logging_collector = on
log_directory = &apos;log&apos;
log_filename = &apos;postgresql-%Y-%m-%d_%H%M%S.log&apos;
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;reading-checkpoint-logs&quot;&gt;Reading Checkpoint Logs&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;log_checkpoints = on&lt;/code&gt;, you’ll see:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;2025-10-17 10:15:42.123 UTC [12345] LOG: checkpoint starting: time
2025-10-17 10:16:11.456 UTC [12345] LOG: checkpoint complete: wrote 2435 buffers (14.9%); 0 WAL file(s) added, 0 removed, 3 recycled; write=29.725 s, sync=0.004 s, total=29.780 s; sync files=7, longest=0.003 s, average=0.001 s; distance=49142 kB, estimate=49142 kB
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Breakdown:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;wrote 2435 buffers (14.9%)    # Dirty pages written (14.9% of shared_buffers)
0 WAL file(s) added           # New WAL segments created
0 removed                     # Old WAL segments deleted
3 recycled                    # WAL segments renamed for reuse
write=29.725 s                # Time spent writing buffers
sync=0.004 s                  # Time spent fsync&apos;ing
total=29.780 s                # Total checkpoint duration
distance=49142 kB             # WAL generated since last checkpoint
estimate=49142 kB             # Estimated WAL to next checkpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;What to watch:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;High write time&lt;/strong&gt;: Checkpoint taking too long
    &lt;ul&gt;
      &lt;li&gt;Check disk I/O performance&lt;/li&gt;
      &lt;li&gt;Consider spreading checkpoint over more time&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Frequent checkpoints&lt;/strong&gt;: If you see many “checkpoint starting: xlog” instead of “checkpoint starting: time”
    &lt;ul&gt;
      &lt;li&gt;Increase &lt;code&gt;max_wal_size&lt;/code&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Large distance&lt;/strong&gt;: Generating lots of WAL
    &lt;ul&gt;
      &lt;li&gt;Normal for write-heavy workloads&lt;/li&gt;
      &lt;li&gt;Ensure &lt;code&gt;max_wal_size&lt;/code&gt; is adequate&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;pg_waldump-inspecting-wal-records&quot;&gt;pg_waldump: Inspecting WAL Records&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_waldump&lt;/code&gt; lets you read WAL files directly:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Find WAL files
ls $PGDATA/pg_wal/

# If this doesn&apos;t work, replace PGDATA with output from SHOW data_directory;

# Dump a WAL segment
pg_waldump $PGDATA/pg_wal/000000010000000000000001

# Output shows each WAL record:
rmgr: Heap        len: 54   rec: INSERT off 1 flags 0x00
rmgr: Btree       len: 72   rec: INSERT_LEAF off 5
rmgr: Transaction len: 34   rec: COMMIT 2025-10-17 10:15:42.123456 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;filtering-wal-records&quot;&gt;Filtering WAL Records&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Show only specific resource manager (Heap = table data)
pg_waldump -r Heap $PGDATA/pg_wal/000000010000000000000001

# Show records from specific LSN range
pg_waldump -s 0/1500000 -e 0/1600000 $PGDATA/pg_wal/000000010000000000000001

# Show statistics summary
pg_waldump --stats $PGDATA/pg_wal/000000010000000000000001
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;understanding-wal-record-output&quot;&gt;Understanding WAL Record Output&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;rmgr: Heap        len: 54   rec: INSERT off 1
    lsn: 0/01500028, prev: 0/01500000, desc: INSERT off 1 flags 0x00
    blkref #0: rel 1663/16384/16385 blk 0
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Breaking this down:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;rmgr: Heap              # Resource manager (table data)
len: 54                 # Record length in bytes
rec: INSERT             # Operation type
lsn: 0/01500028        # Log Sequence Number
prev: 0/01500000       # Previous record LSN
blkref #0:             # Block reference
  rel 1663/16384/16385 # Relation OID (tablespace/database/relation)
  blk 0                # Block number
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;pg_control-database-cluster-state&quot;&gt;pg_control: Database Cluster State&lt;/h2&gt;

&lt;p&gt;View control file (requires command-line tool):&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pg_controldata $PGDATA
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Key information:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;pg_control version number:            1300
Catalog version number:               202107181
Database system identifier:           7012345678901234567
Database cluster state:               in production
pg_control last modified:             Thu 17 Oct 2025 10:15:42 AM UTC
Latest checkpoint location:           0/1500000
Latest checkpoint&apos;s REDO location:    0/1480000
Latest checkpoint&apos;s TimeLineID:       1
Latest checkpoint&apos;s full_page_writes: on
Latest checkpoint&apos;s NextXID:          0:1000
Latest checkpoint&apos;s NextOID:          24576
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This shows the last checkpoint LSN, which is crucial for crash recovery.&lt;/p&gt;

&lt;h2 id=&quot;monitoring-checkpoint-health&quot;&gt;Monitoring Checkpoint Health&lt;/h2&gt;

&lt;p&gt;Create a monitoring query (PostgreSQL 17+):&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE OR REPLACE VIEW checkpoint_health AS
SELECT
    num_timed,
    num_requested,
    round(100.0 * num_requested /
          nullif(num_timed + num_requested, 0), 2
    ) AS req_checkpoint_pct,
    pg_size_pretty(
        buffers_written * 8192::bigint
    ) AS checkpoint_write_size,
    round(
        write_time::numeric /
        nullif(num_timed + num_requested, 0),
        2
    ) AS avg_checkpoint_write_ms,
    round(
        sync_time::numeric /
        nullif(num_timed + num_requested, 0),
        2
    ) AS avg_checkpoint_sync_ms
FROM pg_stat_checkpointer;

-- Check health
SELECT * FROM checkpoint_health;

-- Example output:
-- num_timed: 1200
-- num_requested: 50
-- req_checkpoint_pct: 4.00           ← Good (&amp;lt; 10%)
-- checkpoint_write_size: 4000 MB
-- avg_checkpoint_write_ms: 375.21
-- avg_checkpoint_sync_ms: 2.08
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;For PostgreSQL 16 and earlier&lt;/strong&gt;, use &lt;code&gt;pg_stat_bgwriter&lt;/code&gt; with column names &lt;code&gt;checkpoints_timed&lt;/code&gt;, &lt;code&gt;checkpoints_req&lt;/code&gt;, &lt;code&gt;checkpoint_write_time&lt;/code&gt;, &lt;code&gt;checkpoint_sync_time&lt;/code&gt;, and &lt;code&gt;buffers_checkpoint&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Healthy checkpoint system:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;req_checkpoint_pct&lt;/code&gt; &amp;lt; 10%: Most checkpoints are scheduled&lt;/li&gt;
  &lt;li&gt;Reasonable write times: Not overwhelming the I/O system&lt;/li&gt;
  &lt;li&gt;Consistent checkpoint sizes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;resetting-statistics&quot;&gt;Resetting Statistics&lt;/h2&gt;

&lt;p&gt;Statistics accumulate since the last reset:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Reset all statistics
SELECT pg_stat_reset();

-- Reset bgwriter stats
SELECT pg_stat_reset_shared(&apos;bgwriter&apos;);

-- Reset WAL stats
SELECT pg_stat_reset_shared(&apos;wal&apos;);

-- Check when stats were last reset
SELECT stats_reset FROM pg_stat_bgwriter;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Reset stats to measure recent behavior or after configuration changes.&lt;/p&gt;

&lt;h2 id=&quot;putting-it-all-together&quot;&gt;Putting It All Together&lt;/h2&gt;

&lt;p&gt;A complete checkpoint monitoring query:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WITH wal_rate AS (
    SELECT
        pg_size_pretty(wal_bytes) AS total_wal,
        wal_records AS total_records,
        wal_fpi AS full_page_images
    FROM pg_stat_wal
),
checkpoint_stats AS (
    SELECT
        checkpoints_timed + checkpoints_req AS total_checkpoints,
        checkpoints_req,
        round(100.0 * checkpoints_req /
              nullif(checkpoints_timed + checkpoints_req, 0), 2
        ) AS req_pct,
        pg_size_pretty(buffers_checkpoint * 8192::bigint) AS data_written,
        round(checkpoint_write_time::numeric /
              nullif(checkpoints_timed + checkpoints_req, 0), 2
        ) AS avg_write_ms
    FROM pg_stat_bgwriter
)
SELECT
    c.total_checkpoints,
    c.checkpoints_req,
    c.req_pct || &apos;%&apos; AS req_checkpoint_pct,
    w.total_wal,
    w.total_records,
    w.full_page_images,
    c.data_written AS checkpoint_data_written,
    c.avg_write_ms || &apos; ms&apos; AS avg_checkpoint_write_time
FROM checkpoint_stats c, wal_rate w;
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;You now have the foundational knowledge of PostgreSQL internals:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Memory vs disk performance (&lt;a href=&quot;/postgres-fundamentals-memory-vs-disk-part-1&quot;&gt;Part 1&lt;/a&gt;)&lt;/li&gt;
  &lt;li&gt;How data is stored in pages (&lt;a href=&quot;/postgres-fundamentals-database-storage-part-2&quot;&gt;Part 2&lt;/a&gt;)&lt;/li&gt;
  &lt;li&gt;Transactions and ACID (&lt;a href=&quot;/postgres-fundamentals-transactions-part-3&quot;&gt;Part 3&lt;/a&gt;)&lt;/li&gt;
  &lt;li&gt;Performance trade-offs (&lt;a href=&quot;/postgres-fundamentals-performance-patterns-part-4&quot;&gt;Part 4&lt;/a&gt;)&lt;/li&gt;
  &lt;li&gt;Write-Ahead Logging (&lt;a href=&quot;/postgres-fundamentals-wal-deep-dive-part-5&quot;&gt;Part 5&lt;/a&gt;)&lt;/li&gt;
  &lt;li&gt;Monitoring tools (&lt;a href=&quot;/postgres-fundamentals-monitoring-administration-part-6&quot;&gt;Part 6&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Think I missed out on a key topic? Please reach out to me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Previous&lt;/strong&gt;: &lt;a href=&quot;/postgres-fundamentals-wal-deep-dive-part-5&quot;&gt;Part 5 - Write-Ahead Logging Deep Dive&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next&lt;/strong&gt;: &lt;a href=&quot;/understanding-postgres-checkpoints&quot;&gt;Understanding PostgreSQL Checkpoints&lt;/a&gt;&lt;/p&gt;

&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/monitoring-stats.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;PostgreSQL monitoring statistics documentation (opens in new tab)&quot;&gt;PostgreSQL Documentation: Monitoring Stats&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/pgwaldump.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;PostgreSQL pg_waldump documentation (opens in new tab)&quot;&gt;PostgreSQL Documentation: pg_waldump&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/runtime-config-logging.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; aria-label=&quot;PostgreSQL logging configuration documentation (opens in new tab)&quot;&gt;PostgreSQL Documentation: Server Log&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Thu, 16 Oct 2025 00:00:00 +0000</pubDate>
        <link>https://prateekcodes.com/postgres-fundamentals-monitoring-administration-part-6/</link>
        <guid isPermaLink="true">https://prateekcodes.com/postgres-fundamentals-monitoring-administration-part-6/</guid>
        
        <category>postgres</category>
        
        <category>database-fundamentals</category>
        
        <category>monitoring</category>
        
        <category>administration</category>
        
        <category>pg-waldump</category>
        
        <category>system-views</category>
        
        
        <category>PostgreSQL</category>
        
        <category>Database</category>
        
      </item>
    
  </channel>
</rss>
