# attr_json: typed ActiveRecord attributes stored in one jsonb column

> attr_json is a Ruby gem that gives ActiveRecord models typed attributes backed by a single JSON column, with casting, dirty tracking, embedded models and postgres jsonb containment queries. It is built for postgres, targets Rails 6.0 through 7.0, and its own documentation flags some rough edges in the before_type_cast behaviour.

**jrochkind/attr_json** — Serialized json-hash-backed ActiveRecord attributes, super smooth 

- Repository: https://github.com/jrochkind/attr_json
- Stars: 608 · Forks: 37
- Language: Ruby
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/jrochkind-attr-json

## The idea is one column per model, with typed attributes on top

attr_json stores ActiveRecord attributes serialized in a json column, and the framing in the README is that you can use your database as a typed object store through ActiveRecord, in the same models right next to ordinary column-backed attributes and associations. The claim is that the json-serialized attr_json attributes use as much of the existing ActiveRecord architecture as possible. Concretely, you include AttrJson::Record in a model, declare attributes with the attr_json macro, and those attributes are serialized into a single column. The default column name is json_attributes, though the README notes it can be changed. The attribute types are ActiveModel::Type types, so string, integer, decimal with BigDecimal, float, datetime and boolean are all available, and custom ActiveModel::Type::Value subclasses work too. The rest of the design follows from that choice. Because the values are cast through the ActiveModel type system, they behave like ordinary columns, which is the reason the gem exists rather than a plain serialize call.

## Setting up a model means a jsonb column and a set of attr_json declarations

The basic use example starts with a migration creating a jsonb column and, if you intend to use jsonb containment queries, a GIN index on it:

```ruby
# migration, default column used is `json_attributes, but this can be changed
class CreatMyModels < ActiveRecord::Migration[5.0]
  def change
    create_table :my_models do |t|
      t.jsonb :json_attributes
    end

    # If you plan to do any querying with jsonb_contains below..
    add_index :my_models, :json_attributes, using: :gin
  end
end
```

Then the model itself declares the attributes, including an optional embedded model and array attributes:

```ruby
class MyModel < ActiveRecord::Base
   include AttrJson::Record

   attr_json :my_string, :string
   attr_json :my_integer, :integer
   attr_json :my_datetime, :datetime

   # You can have an _array_ of those things too. It will ordinarily default to empty array.
   attr_json :int_array, :integer, array: true

   # The empty array default can be disabled with the following setting
   attr_json :str_array, :string, array: true, default: AttrJson::AttributeDefinition::NO_DEFAULT_PROVIDED

   #and/or defaults
   attr_json :str_with_default, :string, default: "default value"

   attr_json :embedded_lang_and_val, LangAndValue.to_type
end
```

The embedded model is a plain class that includes AttrJson::Model and declares its own attr_json attributes, which is how a nested structure gets a Ruby object rather than a bare hash. The array flag and the default flag are the two options that shape the shape of the stored data, and the NO_DEFAULT_PROVIDED constant is the documented way to distinguish an unset array from an empty one.

## Casting is where the gem pays for itself

The README's demonstration of casting is the clearest argument for the whole approach. Assigning a string to an integer attribute and reading it back gives you the integer, and assigning a string to an array attribute gives you an array containing the cast value:

```ruby
model = MyModel.new
model.my_integer = "12"
model.my_integer # => 12
model.int_array = "12"
model.int_array # => [12]
model.my_datetime = "2016-01-01 17:45"
model.my_datetime # => a Time object representing that, just like AR would cast
model.embedded_lang_and_val = { value: "val"}
model.embedded_lang_and_val #=> #<LangAndVal:0x000000010c9a7ad8 @attributes={"value"=>"val"...>
```

Assigning a hash to an embedded attribute produces the embedded object, which is the difference between this and a serialized hash column. You can also use ordinary ActiveRecord validation methods with attr_json attributes, since the values are real ActiveModel attributes underneath. The serialized form is a hash of keys in the JSON column, and reading model.json_attributes shows the values already cast to their Ruby representations. The README notes the types are resolvable with ActiveRecord::Type.lookup, and that a custom type can be registered in an initializer with ActiveRecord::Type.register.

## The container column and the store key are both adjustable

Two escape hatches let you fit the gem into an existing schema. The first is the container column. The default assumes you want to serialize into a column called json_attributes, but the README is explicit that you can pick whatever named jsonb column you like, either class-wide or per attribute:

```ruby
class OtherModel < ActiveRecord::Base
  include AttrJson::Record

  # as a default for the model
  attr_json_config(default_container_attribute: :some_other_column_name)

  # now this is going to serialize to column 'some_other_column_name'
  attr_json :my_int, :integer

  # Or on a per-attribute basis
  attr_json :my_int, :integer, container_attribute: "yet_another_column_name"
end
```

The second is the store key, which lets the serialized JSON key differ from the attribute name and the accessor methods. That matters when you are keeping compatibility with an existing JSON payload format:

```ruby
class MyModel < ActiveRecord::Base
  include AttrJson::Record

  attr_json :special_string, :string, store_key: "__my_string"
end

model = MyModel.new
model.special_string = "foo"
model.json_attributes # => {"__my_string"=>"foo"}
model.save!
model.json_attributes_before_type_cast # => string containing: {"__my_string":"foo"}
```

The array, default, store_key and container_attribute options can be combined freely, and you can mix in any type you like. The flexibility is real, and it is also the source of the complexity, since a model can now write into two different columns with different key naming schemes, and nothing in the schema tells a future reader which is which.

## Dirty tracking works, but the before_type_cast methods are inconsistent

This is where the README is unusually candid, and the admission is worth reading before you build audit trails on top of the gem. To see the JSON representations rather than the cast Ruby objects, the README points at the Rails before_type_cast methods, the in_database methods and the _for_database methods, which the README notes are Rails 7.0 and later only. These methods can all be called on the container json_attributes hash attribute, generally showing serialized JSON as a string, or on any individual attribute, generally showing an in-memory JSON-able object. The README then states that this is a bit confusing and possibly not entirely consistent, and that it needs more investigation. That sentence is the whole caveat in the project's own words. The practical consequence is that if you are writing change tracking that compares what is in the database against what is in memory, the two levels of the API do not behave symmetrically, and the README does not document a workaround. The repository layout suggests where to look next, with a CHANGELOG.md, a doc_src/ directory, a spec/ directory and an Appraisals file for testing against multiple dependency sets, but the documentation itself is candid that this corner is not settled.

## Querying needs an explicit scope include and a GIN index

Query support comes from postgres jsonb containment, which uses the @> operator, and it is opt-in rather than automatic. To get the behaviour you additionally include AttrJson::Record::QueryScopes in the model:

```ruby
model = MyModel.create(my_string: "foo", my_integer: 100)

MyModel.jsonb_contains(my_string: "foo", my_integer: 100).to_sql
# SELECT "products".* FROM "products" WHERE (products.json_attributes @> ('{"my_string":"foo","my_integer":100}')::jsonb)
```

The generated SQL shows what the scope does, which is to build a containment predicate against the container column with the hash of the given attributes. The README notes that for now you need that additional include to get this behaviour, which is an honest statement that the feature is opt-in and the surface is still being shaped. The GIN index from the migration is what makes the containment lookup fast, and the migration comment explicitly ties the two together, saying to add the index if you plan to do any querying with jsonb_contains. The database is the constraint layer here, and containment on an unindexed jsonb column is a full scan, so skipping the index turns a cheap lookup into an expensive one without any error being raised.

## Postgres first, MySQL untested, and a gem with a versioned support range

The support boundaries are stated at the top of the README and they are the first thing to check. The gem is for Rails 6.0.x through 7.0.x and requires Ruby 2.7 or newer, and there is a CI badge specifically for future Rails versions alongside the ordinary CI badge, which suggests the project tracks new Rails releases even where the supported range has not moved. It was developed for postgres, and most features should work with MySQL json columns too, although that has not yet been tested with MySQL. The word yet is doing real work in that sentence. The gem is versioned with the standard Ruby tooling, with an attr_json.gemspec, a Gemfile, a Rakefile and an Appraisals file for testing against several dependency sets, and the repository publishes no GitHub releases, so the published gem on RubyGems is the artefact to pin. The licence is MIT with a LICENSE.txt at the root. For a gem in this position, the practical upgrade question is which Rails versions your app is on, since the supported range is narrower than the gem's history and a Rails upgrade to 7.1 or later is the event that would force a decision.

## Conclusion

attr_json suits Rails teams that want loosely shaped per-record data without leaving ActiveRecord, that want the same casting and validation behaviour as ordinary columns, and that need to query into the JSON with postgres jsonb containment. It is the wrong choice if you need database-enforced constraints on that data, since the column is untyped JSON, and it is the wrong choice for MySQL users, because the README says the MySQL support has not been tested. Verify first that your Rails version is inside the supported 6.0.x through 7.0.x range and your Ruby is 2.7 or newer, that the containment index you add actually matches the queries you plan to run, and how the before_type_cast methods behave on the container attribute before you rely on them for audit logging.

## FAQ

### What does attr_json do in an ActiveRecord model?

It lets you declare typed attributes that are serialized into a single json or jsonb column, by including AttrJson::Record in the model and using the attr_json macro with ActiveModel types such as string, integer, float, decimal, datetime and boolean.

### Which databases does attr_json support?

It was developed for postgres, and the README says most features should work with MySQL json columns too, although it has not yet been tested with MySQL. The querying support uses postgres jsonb containment.

### How do I query inside a jsonb column with attr_json?

Include AttrJson::Record::QueryScopes in your model and use the jsonb_contains scope, which generates a containment predicate against the container column. The migration example adds a GIN index on that column if you plan to query it this way.

### Which Rails and Ruby versions does attr_json support?

The README states support for Rails 6.0.x through 7.0.x with Ruby 2.7 and newer. The repository publishes no GitHub releases, so the gem published on RubyGems is the artefact to depend on.

## Sources

- [Issues](https://github.com/jrochkind/attr_json/issues)
- [jrochkind/attr_json on GitHub](https://github.com/jrochkind/attr_json)
- [License: MIT](https://github.com/jrochkind/attr_json/blob/master/LICENSE)
- [README](https://github.com/jrochkind/attr_json/blob/master/README.md)

---

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