# jsonb_accessor: typed fields on top of a Postgres jsonb column, and the scopes that query them

> jsonb_accessor is a Ruby gem that adds typed jsonb backed fields as first class citizens to ActiveRecord models, with type casting, defaults, store key renaming, prefixes and suffixes, and query scopes that let you filter on jsonb values with operators, ranges, and plain English comparisons. It is in maintenance mode and MIT licensed.

**madeintandem/jsonb_accessor** — Adds typed jsonb backed fields to your ActiveRecord models.

- Repository: https://github.com/madeintandem/jsonb_accessor
- Stars: 1,127 · Forks: 99
- Language: Ruby
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/madeintandem-jsonb-accessor

## A jsonb column that behaves like a table of columns

jsonb_accessor adds typed jsonb backed fields as first class citizens to your ActiveRecord models. The gem is similar in spirit to HstoreAccessor, and the project explains why Postgres jsonb is the better base: the jsonb column in PostgreSQL has a few distinct advantages, mostly around nested documents and support for collections.

The mechanism is a class-level declaration. You create a model with a jsonb column, and then you declare which fields inside that column should be exposed as attributes:

```ruby
class Product < ActiveRecord::Base
  jsonb_accessor :data,
    title: :string,
    external_id: :integer,
    reviewed_at: :datetime
end
```

The first argument is the jsonb column name, and the rest is a hash of field name to type. Any type the ActiveRecord `attribute` API supports works, and the project notes you can also implement your own type by following the example in the `attribute` documentation.

That last point is the design decision that makes this more than a convenience. Rather than implementing its own type system, the gem leans on the framework's. A `jsonb_accessor` field is an ActiveRecord attribute, which means it gets the framework's type casting, its dirty tracking, its serialization, and its interaction with the rest of the ORM, and a custom type is a custom type in the ordinary ActiveRecord sense.

The column itself is created with a normal migration:

```ruby
class CreateProducts < ActiveRecord::Migration
  def change
    create_table :products do |t|
      t.jsonb :data
    end
  end
end
```

So the shape of the data is a jsonb column, and the shape of the code is a set of columns. That mismatch is the whole point of the gem, and it is why a schema-less column often becomes a de facto table.

## Defaults, arrays, and renamed store keys

The options you can pass to a field are the options of the underlying attribute API, passed in an array.

```ruby
class Product < ActiveRecord::Base
  jsonb_accessor :data,
    title: [:string, default: "Untitled"],
    previous_titles: [:string, array: true, default: []]
end
```

The `default` option works pretty much as you would expect in practice. If no values are set for the attributes, a hash of the specified default values is saved to the jsonb column. That phrasing matters, because it describes the mechanism rather than the effect: the defaults materialise in the column on save rather than appearing to exist in memory.

The `array: true` option gives you a collection inside the jsonb, which is one of the two advantages the project claims for jsonb over hstore, alongside nested documents.

The `store_key` option is the one that changes the data rather than the code. You can use `title` for the getters and setters while the key in the jsonb column is something else:

```ruby
class Product < ActiveRecord::Base
  jsonb_accessor :data, title: [:string, store_key: :t]
end
```

```ruby
product = Product.new(title: "Foo")
product.title #=> "Foo"
product.data #=> { "t" => "Foo" }
```

The `prefix` and `suffix` options do the opposite. They let the accessor name differ from the key in the column, and they can also be set to `true` to use the jsonb accessor name as the prefix or suffix:

```ruby
class Product < ActiveRecord::Base
  jsonb_accessor :data,
    title: [:string, prefix: :data],
    external_id: [:integer, suffix: :attr]
end
```

```ruby
product = Product.new(data_title: "Foo", external_id_attr: 12314122)
product.data_title #=> "Foo"
product.external_id_attr #=> 12314122
product.data #=> { "title" => "Foo", "external_id" => 12314122 }
```

The pair of options covers both directions, and the second example shows the difference clearly: with `prefix`, the accessor is `data_title` and the key is `title`, while with `store_key`, the accessor is `title` and the key is `t`. The distinction is worth keeping straight, because the two look similar in a declaration and produce opposite results in the stored data.

## Global options apply to every field, and can be overridden

Options can also be applied to all fields at once by passing an options hash as the second parameter:

```ruby
class Product < ActiveRecord::Base
  jsonb_accessor :data, { prefix: true },
    title: :string,
    external_id: :integer,
    price: :decimal
end
```

This applies the prefix to all fields, and you can still override it for individual fields:

```ruby
class Product < ActiveRecord::Base
  jsonb_accessor :data, { prefix: :product },
    title: :string,
    external_id: [:integer, prefix: :custom],
    price: :decimal
end

product = Product.new(product_title: "Widget", custom_external_id: 123, product_price: 19.99)
product.product_title #=> "Widget"
product.custom_external_id #=> 123
product.product_price #=> 19.99
product.data #=> { "title" => "Widget", "external_id" => 123, "price" => 19.99 }
```

Global options currently support `:prefix` and `:suffix`.

The current limitation is worth stating plainly, because it defines what you can do. The global options hash is not a general pass-through of the attribute API options, it is a two-key namespace for prefix and suffix. Anything else you want applied to every field, such as a default, you still have to write out per field.

The per-field override is a plain last-one-wins. In the example, `external_id` gets `prefix: :custom` while `title` and `price` inherit `prefix: :product`, and the stored keys are still the clean `title`, `external_id`, and `price`. The prefix and suffix affect the Ruby method names, not the column keys, so the jsonb data stays tidy no matter how you name the accessors.

## Scopes that query jsonb without hand-writing SQL

The gem also adds generic scopes for querying jsonb columns, and they are the part that removes the last reason to reach for raw SQL.

Four scopes are available on all ActiveRecord::Base subclasses and do not require that you make use of the jsonb_accessor declaration: `jsonb_contains`, `jsonb_number_where`, `jsonb_time_where`, and `jsonb_where`. The generic `jsonb_where` takes store keys directly, which is what you need when the column is not declared through the accessor.

When a class does have a jsonb_accessor declaration, one custom scope is defined, named after the json column with a `_where` suffix, so a `data` column gives you `data_where`, and a `data_where_not` alongside it:

```ruby
Product.all.data_where(name: "Granite Towel", price: 17)
```

```ruby
Product.all.data_where_not(name: "Plasma Fork")
```

For number fields you can query using the operators or plain English, whichever you prefer:

```ruby
Product.all.data_where(price: { <: 15 })
Product.all.data_where(price: { <=: 15 })
Product.all.data_where(price: { less_than: 15 })
Product.all.data_where(price: { less_than_or_equal_to: 15 })

Product.all.data_where(price: { >: 15 })
Product.all.data_where(price: { >=: 15 })
Product.all.data_where(price: { greater_than: 15 })
Product.all.data_where(price: { greater_than_or_equal_to: 15 })

Product.all.data_where(price: { greater_than: 15, less_than: 30 })
```

Time related fields use `before` and `after`:

```ruby
Product.all.data_where(reviewed_at: { before: Time.current.beginning_of_week, after: 4.weeks.ago })
```

And ranges work directly, for records within a certain time, date, or number range, as shorthand for the options above:

```ruby
Product.all.data_where(price: 10..20)
Product.all.data_where(price: 10...20)
Product.all.data_where(reviewed_at: Time.current..3.days.from_now)
```

The scope is a convenient wrapper around the `jsonb_where` scope that saves you from having to convert the given keys to the store keys, which is the whole reason it exists when you have renamed keys with `store_key`.

The one thing to keep in mind is that all of this compiles down to a jsonb containment or comparison query, and jsonb queries on a large table are not free. The scopes make the queries readable, not the indexes.

## Maintenance mode is the headline, and it should be your filter

The status section is the first thing after the introduction, and it is the most important paragraph in the project summary.

This gem is in maintenance mode and no active development of new features is planned. The major focus is to keep it working with new Ruby and Rails versions and fix any bugs reported. Any pull requests for feature requests or enhancements will be reviewed and merged, so contributions are encouraged.

Read that carefully, because the middle sentence and the last sentence are in tension and both are true. The project will not originate features, but it will accept them. That is a workable arrangement for a small gem, and it is also a clear signal about the risk profile of adopting it.

The release history is consistent with it. The tags are v1.4 from 2023-10-15, v1.4.1 from 2026-01-22, and v1.4.2 from 2026-03-20. Two patch releases in 2026 after a long gap in 2023, which is what a compatibility maintenance stream looks like: a Rails or Ruby release lands, something breaks, a patch fixes it.

The last push to the repository was on 2026-06-22, and the project is not archived. So the code is still being touched and the gem still installs, but the trajectory is clear, and the maintenance statement is the project telling you that in as many words.

The repository reflects the same working practices. There is an `UPGRADE_GUIDE.md` at the root, which is what a gem with behaviour changes across versions carries, and a `CHANGELOG.md`. There is a `Makefile` with two build targets, one for a standard Ruby build and one for JRuby, and the JRuby one passes `--platform java` to the gem build, which is the difference between a native and a Java platform gem. Both build inside Docker, with a version argument for the Ruby or JRuby image.

There is a `.ruby-version` file, a `Gemfile` and `Rakefile` for the usual development loop, a `.rspec` for the spec runner, a `.rubocop.yml` for the linter, and `spec/` and `db/` directories. The gem is published as `jsonb_accessor`, built from `jsonb_accessor.gemspec`.

## Where jsonb_accessor is the wrong choice

The boundaries here are about data modelling rather than about the gem's quality.

First, the fundamental question is whether the jsonb column should hold attributes at all. This gem's premise is that a jsonb column is a table of columns in disguise, and if that is true for your data, the honest answer is usually a real column, or a separate table, or a document store. The gem is at its best in the specific case where the schema is genuinely fluid but a handful of fields are queried and validated constantly, and at its worst when a jsonb column has quietly become a second schema that nobody is validating.

Second, the maintenance mode is a real constraint for a library in your data access layer. It is not a dependency you can upgrade and expect new capability from, and if a future Rails release changes the attribute API in a way this gem does not adapt to, the fix depends on someone else doing it. The gem's own compatibility surface is the ActiveRecord attribute API, and that API is where the risk lives.

Third, the query performance is not addressed by any of this. The scopes make jsonb queries readable, and jsonb containment queries on large tables need appropriate indexes and can be slower than querying real columns. A dataset where the jsonb fields are filtered on every request is a dataset where a column-based schema would be faster, and the gem does not change that.

Fourth, the option surface is uneven. The `attribute` API options are passed per field, while the global options hash supports only `:prefix` and `:suffix`. If you want a consistent default or a consistent array type across a hundred fields, you are writing it out a hundred times.

Fifth, and this is a general caveat rather than a specific one, the `store_key` and prefix and suffix options make the mapping between the Ruby attribute names and the stored keys non-obvious to anyone reading the database. That is a deliberate trade, and it is the right one when the stored keys come from an external system, but it is a cost when the keys are chosen for readability and the accessor names are chosen for a different reason.

Sixth, the gem is Postgres only, because jsonb is a Postgres type. A team that wants to change database engines later is signing up for work, and one that has no Postgres jsonb column at all cannot use it.

## Conclusion

jsonb_accessor fits a Rails team with a Postgres jsonb column that has grown into a set of real attributes, where they want ActiveRecord typing, validations, and query scopes rather than raw hash access. It is the wrong choice if your data is genuinely schemaless and better served by a document store, and it is the wrong choice for a new project, because the project is in maintenance mode with no active development of new features planned. Check three things before adopting it: that you accept a gem that only maintains compatibility and fixes bugs, that the jsonb column is on Postgres, and that you read the upgrade guide at the root of the repository, because a gem with an UPGRADE_GUIDE.md has changed its behaviour across versions.

## FAQ

### What does jsonb_accessor add to an ActiveRecord model?

It adds typed jsonb backed fields as first class citizens to the model, so keys inside a jsonb column behave like ordinary attributes with type casting, defaults, and dirty tracking. The gem is similar in spirit to HstoreAccessor, and jsonb has advantages around nested documents and support for collections.

### How do I install jsonb_accessor and declare the fields?

Add `gem "jsonb_accessor"` to your Gemfile and run `bundle install`. Then declare the accessor on the model, for example `jsonb_accessor :data, title: :string, external_id: :integer, reviewed_at: :datetime`, where the first argument is the jsonb column and the rest are field name and type pairs.

### How do I query jsonb columns with jsonb_accessor?

Four generic scopes are available on all ActiveRecord::Base subclasses without needing a declaration: `jsonb_contains`, `jsonb_number_where`, `jsonb_time_where`, and `jsonb_where`. A class with a `jsonb_accessor :data` declaration also gets a `data_where` scope and a `data_where_not` scope, supporting operator keys such as `<:`, plain English such as `less_than:`, `before` and `after` for times, and Ruby ranges.

### Is jsonb_accessor still actively developed?

The project states it is in maintenance mode with no active development of new features planned, and the focus is on keeping it working with new Ruby and Rails versions and fixing reported bugs, though pull requests for features are still reviewed and merged. The last release was v1.4.2 on 2026-03-20 and the last push was on 2026-06-22.

## Sources

- [Issues](https://github.com/madeintandem/jsonb_accessor/issues)
- [License: MIT](https://github.com/madeintandem/jsonb_accessor/blob/master/LICENSE)
- [madeintandem/jsonb_accessor on GitHub](https://github.com/madeintandem/jsonb_accessor)
- [README](https://github.com/madeintandem/jsonb_accessor/blob/master/README.md)
- [Releases](https://github.com/madeintandem/jsonb_accessor/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/madeintandem-jsonb-accessor
