When “is this record active?” needs a better answer than checking two columns and hoping for the best.

Most Rails applications I’ve worked on have at least one model where records come and go on a schedule. Banners that appear for a campaign and then expire. Subscriptions that start on a date and end on another. Listings that get published now and stay live until the owner takes them down, which might be never.

The pattern is always the same: two timestamp columns, something like published_at and expired_at, and a scope that figures out which records are currently active. And the implementation almost always looks like this:

scope :active, -> { where("published_at <= ? AND expired_at >= ?", Time.current, Time.current) }

That works right up until someone creates a record that should never expire. They leave expired_at as nil, which is the natural way to say “this is permanent.” And then it vanishes from the scope, because comparing anything against NULL in SQL doesn’t return false. It returns NULL. The record silently drops out of your results.

The usual fix is to bolt on an extra condition:

scope :active, -> {
  where("published_at <= ? AND (expired_at >= ? OR expired_at IS NULL)", Time.current, Time.current)
}

This works, but it’s fragile. Every developer who touches this scope needs to remember the NULL case. Every new model with the same pattern gets a fresh chance to forget it. And it gets worse when you start composing scopes or adding edge cases around boundary times.

I recently found a cleaner approach using PostgreSQL’s range types, and it solves the NULL problem at the query level rather than patch around it.

What tsrange gives you

PostgreSQL has a family of range types for different data types. tsrange is the timestamp version. It represents a range between two timestamps as a single value, and it comes with a containment operator, @>, that answers the question “does this range contain this point?”

The useful part for us is that a range can have an unbounded upper end. PostgreSQL has a special value, infinity, that compares as greater than any finite timestamp. If expired_at is NULL, we substitute infinity, and the range becomes “from published_at until forever.” No special NULL handling needed.

Here’s what the scope looks like:

scope :active, lambda { |now = Time.current|
  where(
    "tsrange(published_at, COALESCE(expired_at, timestamp 'infinity')::timestamp, '[]') @> ?::timestamp",
    now
  )
}

That’s a lot of SQL packed into one line, so let me break it down.

tsrange(lower, upper, bounds) constructs a range value. The first argument is published_at, the second is the upper bound, and the third controls whether the endpoints are inclusive or exclusive.

COALESCE(expired_at, timestamp 'infinity') is doing the heavy lifting. If expired_at has a value, use it. If it’s NULL, use infinity. The ::timestamp cast is defensive, making sure the result stays as timestamp without time zone to match the column types. Without it, you can end up with a type mismatch error because Rails binds Time.current as timestamptz.

'[]' means both ends are inclusive. A record where now equals exactly published_at or exactly expired_at counts as active. If you wanted exclusive on either end, it’s a one-character change: '[)' or '(]'.

@> is the containment operator. “Does this range contain this timestamp?” One expression, one check, and NULL records behave correctly because they’ve been turned into unbounded ranges.

The Ruby-side mirror

The scope handles queries, but sometimes you need to check a single record in memory. I like having an instance method that mirrors the SQL logic so the two always agree:

def active?(now = Time.current)
  return false if draft?

  expiry = expired_at.presence || DateTime::Infinity.new
  active_range = (published_at..expiry)
  active_range.cover?(now)
end

DateTime::Infinity.new plays the same role as PostgreSQL’s infinity. It’s a value that compares as greater than any finite time. Range#cover? plays the same role as @>. The two implementations are intentionally parallel.

One thing worth noting: DateTime::Infinity comparisons with Time objects rely on ActiveSupport’s compare_with_coercion. That’s always available in a Rails app, but it wouldn’t work in plain Ruby.

Why passing now matters

You might have noticed that both the scope and the instance method accept a now parameter that defaults to Time.current. This is the detail that makes everything testable.

If the scope just used Time.current internally, your tests would need to use travel_to every time you wanted to check behaviour at different points in time. That works, but it’s noisy. It also makes the tests harder to read because the time manipulation is infrastructure, not intent.

With the parameter, you can write tests that read like specifications:

RSpec.describe Banner, type: :model do
  let(:now) { Time.zone.parse("2026-07-23 12:00") }

  let!(:permanent) do
    create(:banner, published_at: 1.day.ago(now), expired_at: nil)
  end

  let!(:temporary) do
    create(:banner, published_at: 1.day.ago(now), expired_at: 1.day.from_now(now))
  end

  let!(:expired) do
    create(:banner, published_at: 2.days.ago(now), expired_at: 1.day.ago(now))
  end

  let!(:scheduled) do
    create(:banner, published_at: 1.day.from_now(now), expired_at: nil)
  end

  describe ".active" do
    it "includes permanent and temporary banners" do
      expect(Banner.active(now)).to contain_exactly(permanent, temporary)
    end

    it "excludes expired and scheduled banners" do
      expect(Banner.active(now)).not_to include(expired, scheduled)
    end
  end
end

No time travel, no frozen clocks. The now parameter makes the time explicit in the test, which makes the intent obvious.

You can also verify that the SQL scope and the Ruby instance method always agree. This is a useful safety net when you have two implementations of the same logic:

it "SQL and Ruby agree on every record" do
  active_banners = Banner.active(now)
  expect(active_banners.map { |b| b.active?(now) }).to be_all(true)

  inactive_banners = Banner.all.excluding(active_banners)
  expect(inactive_banners.map { |b| b.active?(now) }).to be_all(false)
end

If someone changes the boundary semantics in the SQL scope but forgets to update the Ruby method, this test catches it.

Generalising with a concern

The tsrange pattern works for any model with a start/end timestamp pair. Banners, subscriptions, promotions, and pricing tiers. You can extract a concern rather than copy the scope into each model:

# app/models/concerns/activatable.rb
module Activatable
  extend ActiveSupport::Concern

  included do
    scope :active, lambda { |now = Time.current|
      where(
        "tsrange(#{active_start_column}, COALESCE(#{active_end_column}, timestamp 'infinity')::timestamp, '[]') @> ?::timestamp",
        now
      )
    }

    scope :expired, lambda { |now = Time.current|
      where.not(active_end_column => nil)
           .where("#{active_end_column} < ?", now)
    }

    scope :scheduled, lambda { |now = Time.current|
      where("#{active_start_column} > ?", now)
    }
  end

  class_methods do
    def active_start_column
      :published_at
    end

    def active_end_column
      :expired_at
    end
  end

  def active?(now = Time.current)
    start_time = send(self.class.active_start_column)
    end_time = send(self.class.active_end_column)

    return false if start_time.nil? || start_time > now

    expiry = end_time.presence || DateTime::Infinity.new
    (start_time..expiry).cover?(now)
  end
end

Models override the column names when they differ from the defaults:

class Banner < ApplicationRecord
  include Activatable
end

class Subscription < ApplicationRecord
  include Activatable

  def self.active_start_column = :starts_at
  def self.active_end_column   = :ends_at
end

class Promotion < ApplicationRecord
  include Activatable

  def self.active_start_column = :valid_from
  def self.active_end_column   = :valid_until
end

Every model gets .active, .expired, .scheduled, and #active? with the same testable now parameter. The SQL and Ruby implementations stay in sync because they’re defined once.

Taking it further with CurrentAttributes

Here’s where this gets interesting. In applications I’ve worked on, the question “is this record active?” comes up constantly, and it’s almost always “active right now.” But “right now” isn’t always Time.current. Sometimes you need to see what was active yesterday, or what will be active next week. An admin previewing scheduled content, for example, or a report looking at historical state.

The now parameter handles this, but passing it through every method call is tedious. If a controller action needs to check active banners, active subscriptions, and active promotions for a preview at a future date, you’re threading that timestamp through three separate calls.

CurrentAttributes gives you a place to set it once per request:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :active_at, default: -> { Time.current }
end

Now update the concern to use it as the default:

module Activatable
  extend ActiveSupport::Concern

  included do
    scope :active, lambda { |now = Current.active_at|
      where(
        "tsrange(#{active_start_column}, COALESCE(#{active_end_column}, timestamp 'infinity')::timestamp, '[]') @> ?::timestamp",
        now
      )
    }
  end

  def active?(now = Current.active_at)
    # same implementation as before
  end
end

In normal requests, Current.active_at returns Time.current and everything works as before. But when you need a different perspective on time, you set it once at the controller level:

class Admin::PreviewsController < ApplicationController
  before_action :set_preview_time

  def show
    @banners      = Banner.active
    @promotions   = Promotion.active
    @subscription = current_account.subscriptions.active.first
  end

  private

  def set_preview_time
    Current.active_at = Time.zone.parse(params[:at]) if params[:at].present?
  end
end

None of the model code changes. Every .active call in the action automatically uses the preview time because it’s reading from Current.active_at. The controller sets the context, and the models respond to it.

This also works well in background jobs. If a job needs to process records that were active at a specific time, it can set Current.active_at at the start of the job and every scope in the job will use that time:

class DailyReportJob < ApplicationJob
  def perform(report_date)
    Current.set(active_at: report_date.end_of_day) do
      active_banners       = Banner.active
      active_subscriptions = Subscription.active
      # everything here sees the world as it was at end of report_date
    end
  end
end

The Current.set block is useful here because it resets the attribute when the block ends, which keeps the job from leaking state if it runs multiple reports in sequence.

Testing stays clean

The explicit now parameter still works exactly as before. In tests, you can pass a specific time directly to the scope or instance method without touching CurrentAttributes at all:

expect(Banner.active(some_specific_time)).to include(banner)
expect(banner.active?(some_specific_time)).to be true

CurrentAttributes is the default for production convenience. The parameter is the escape hatch for testing and for any code that needs to be explicit about the time it’s checking.

A note on PostgreSQL

This approach depends on PostgreSQL. The tsrange type, the @> operator, and the infinity literal are all PostgreSQL-specific. If you’re on MySQL or SQLite, you’d need to stick with the COALESCE and OR IS NULL approach. PostgreSQL also has daterange for date-only columns and int4range for integer ranges, which follow the same containment pattern.

The Ruby side works in any Rails app because DateTime::Infinity and Range#cover? are part of ActiveSupport and Ruby’s standard library respectively.

There we have it

A tsrange scope, a Ruby mirror that uses DateTime::Infinity, and a now parameter that makes both testable. Wrap it in a concern so any model with a start/end timestamp pair gets it for free. Default the now to a CurrentAttribute so the whole request shares one perspective on time, and you can preview the future or inspect the past by changing it in one place.

The real win here is that “is this record active?” becomes a question with one answer, defined once, tested once, and used everywhere, without NULL edge cases to forget, time-travel helpers cluttering up your tests, or timestamps threaded through every method call.

Ship it.