How to Update JSON Columns in Ruby on Rails (2026)
- Use a normal
update! when the JSON document is small, updates are infrequent and model validations or callbacks matter.-
update_column and update_columns do not update an individual nested JSON key. They still replace the value stored in the column and bypass normal Rails validations and callbacks.- Use PostgreSQL's
jsonb_set or MySQL's JSON_SET when you need to change a path directly in the database.- Use a database-level JSON update, pessimistic locking or Rails optimistic locking to prevent lost updates.
- In PostgreSQL, use GIN for
jsonb containment and key searches. Use a B-tree expression index when filtering by one extracted scalar value.- Enforce uniqueness with a database index. An application-only uniqueness check remains vulnerable to race conditions.
- If individual JSON members are queried, validated and updated like independent records, model them in a separate table.
JSON columns are useful when an application needs to store flexible or nested data without adding a new relational column for every optional attribute. I have used them for preferences, integration payloads, feature configuration and progress-tracking data where the shape can evolve over time.
The difficult part is rarely storing the JSON. It is updating one value without losing another process's changes, bypassing important validations or turning every query into a full-table scan.
In this guide, I will explain the strategies I use for updating JSON columns in Ruby on Rails, from straightforward Active Record updates to atomic PostgreSQL and MySQL operations. We will also cover validation, indexing, concurrency and the point at which JSON should become a relational table.
A Practical JSON Example
Suppose a user_courses table stores progress for each piece of course content:
{
"content": {
"148": {
"status": "in_progress",
"progress_in_sec": 120
}
},
"last_opened_at": "2026-08-17T08:30:00Z"
}For PostgreSQL, I would normally use jsonb rather than json. PostgreSQL stores jsonb in a decomposed binary format and supports the operators and indexes needed for efficient JSON queries.
class AddProgressToUserCourses < ActiveRecord::Migration[8.1]
def change
add_column :user_courses, :progress, :jsonb, null: false, default: {}
end
endFor MySQL, use its native json column type:
class AddProgressToUserCourses < ActiveRecord::Migration[8.1]
def change
add_column :user_courses, :progress, :json, null: false
end
endIf the table already contains rows, use a staged migration: add the column as nullable, backfill every row, and then add the NOT NULL constraint. That avoids depending on database-version-specific JSON default behavior during deployment.
The examples use string keys because Rails' structured JSON attributes are string-keyed. Mixing string and symbol keys is an easy way to read or update the wrong path.
Why JSON Updates Become Difficult
Nested paths
Changing content.148.status requires preserving every sibling key around it. Replacing the wrong level of the hash can removeprogress_in_sec, other content entries, or document-level metadata.
Lost updates
Consider two workers that load the same record. One changes the status while the other changes the elapsed time. If both read the full document, modify it in Ruby, and save it, the second write can silently replace the first worker's change.
Validation
Rails can validate top-level JSON accessors, but deeply nested, conditional structures usually need custom validation. Database constraints are also harder to express than they are for normal relational columns.
Query performance
A JSON column is not automatically indexed for every possible path. The correct index depends on the query: containment, key existence and scalar equality require different index strategies.
Partial updates
Rails treats the JSON document as one model attribute. Updating the attribute through Active Record normally sends a new value for that column. To modify a nested path inside the database, use the JSON functions provided by PostgreSQL or MySQL.
Strategy 1: Read, Modify and Save Through Active Record
For small documents and low-contention workflows, the simplest approach is often the most maintainable:
user_course = UserCourse.find(42)
updated_progress = user_course.progress.deep_dup
entry = updated_progress.fetch("content").fetch("148")
entry["status"] = "completed"
entry["progress_in_sec"] = 360
user_course.update!(progress: updated_progress)I prefer assigning a copied hash instead of relying on an in-place mutation. It makes the intended change obvious and avoids accidental modification of a shared nested object.
update! is a good choice here because it:
- runs model validations;
- runs callbacks;
- updates timestamps; and
- raises an exception if the record cannot be saved.
The limitation is that this is a read-modify-write sequence. It also writes the JSON column as an attribute rather than issuing a path-level JSON operation.
Using store_accessor for stable top-level keys
If a JSON column contains a few stable top-level properties, store_accessor can make them behave more like normal model attributes:
class User < ApplicationRecord
store_accessor :profile, :email, :display_name
validates :email, presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
endYou can then write:
user.update!(email: "dev@example.com")This improves model ergonomics, but it does not turn email into an independent database column. Rails is still persisting it inside profile, and store_accessor is primarily useful for top-level keys rather than arbitrary nested paths.
Let’s Build Scalable Apps with Ruby on Rails!
We build robust Ruby on Rails applications that scale smoothly and deliver high performance from day one.
Why update_column is not a nested JSON update
The following code skips the initial SELECT, but it still supplies a replacement value for the complete progress attribute:
user_course.update_column(:progress, updated_progress)According to the Rails persistence API, update_column delegates to update_columns. These methods bypass validations and callbacks, and they do not update updated_at unless it is included explicitly. They can be fast, but they are not equivalents of PostgreSQL jsonb_set or MySQL JSON_SET.
Use them only when bypassing the normal model lifecycle is deliberate.
Strategy 2: Update a PostgreSQL jsonb Path Atomically
PostgreSQL's jsonb_set(target, path, new_value, create_if_missing) returns the document with the value at the specified path replaced. This allows the database to perform the change in one UPDATE statement.
content_id = "148"
new_status = "completed"
UserCourse.where(id: 42).update_all([
<<~SQL.squish,
progress = jsonb_set(
progress,
ARRAY['content', ?, 'status']::text[],
to_jsonb(?::text),
true
),
updated_at = ?
SQL
content_id,
new_status,
Time.current
])The values are bound separately rather than interpolated into SQL. This matters when a path segment or new value comes from a request.
There is one important detail: create_if_missing: true can create the final item, but the earlier steps in the path must already exist. In this example, content and content.148 must exist. If they might be absent, initialize that structure first or use a consistent default document shape.
Updating multiple nested values in one statement
Nest jsonb_set calls when multiple paths must change together:
content_id = "148"
new_status = "completed"
seconds = 360
UserCourse.where(id: 42).update_all([
<<~SQL.squish,
progress = jsonb_set(
jsonb_set(
progress,
ARRAY['content', ?, 'status']::text[],
to_jsonb(?::text),
true
),
ARRAY['content', ?, 'progress_in_sec']::text[],
to_jsonb(?::integer),
true
),
updated_at = ?
SQL
content_id,
new_status,
content_id,
seconds,
Time.current
])Both changes are part of one SQL statement. There is no application-side window between reading the document and writing it back.
Direct relation updates do not run the normal Active Record validations or callbacks. I therefore keep this logic behind a focused service method, validate incoming values before the query and test the database result explicitly.
Strategy 3: Update a MySQL JSON Path With JSON_SET
MySQL provides JSON_SET(json_doc, path, value, ...). It can update more than one path in the same call:
content_id = Integer(params[:content_id]).to_s
new_status = "completed"
seconds = 360
content_path = %{$.content."#{content_id}"}
UserCourse.where(id: 42).update_all([
<<~SQL.squish,
progress = JSON_SET(
progress,
?, CAST(? AS JSON),
?, CAST(? AS JSON)
),
updated_at = ?
SQL
"#{content_path}.status",
new_status.to_json,
"#{content_path}.progress_in_sec",
seconds.to_json,
Time.current
])CAST(... AS JSON) preserves the intended JSON type. Without deliberate type handling, it is easy to store a number as a string or a structured value as escaped text.
MySQL can optimize some JSON_SET, JSON_REPLACE and JSON_REMOVE operations as partial in-place updates when its documented conditions are satisfied. Application code should still be designed for correctness rather than assuming every JSON update will qualify for that storage optimization.
As with PostgreSQL, update_all bypasses the ordinary Rails validation and callback flow.
Strategy 4: Protect Read-Modify-Write Logic From Concurrency
An atomic JSON function is ideal when one statement can express the change. When the update depends on more complex business logic, use a locking strategy.
Pessimistic locking
Pessimistic locking obtains a database row lock while the transaction calculates and saves the new document:
UserCourse.transaction do
user_course = UserCourse.lock.find(42)
updated_progress = user_course.progress.deep_dup
entry = updated_progress.fetch("content").fetch("148")
entry["status"] = "completed"
user_course.update!(progress: updated_progress)
endOther transactions trying to acquire a conflicting lock must wait. Keep the transaction short: do not call external APIs or perform slow, unrelated work while holding the lock.
Optimistic locking
Optimistic locking is useful when conflicts are uncommon. Add a lock_version column:
class AddLockVersionToUserCourses < ActiveRecord::Migration[8.1]
def change
add_column :user_courses, :lock_version,
:integer,
null: false,
default: 0
end
endRails then includes the version in model updates. If another process saves the record first, Rails raises ActiveRecord::StaleObjectError instead of silently overwriting the newer value. The application must decide whether to show a conflict, reload and merge, or perform a bounded retry.
Use model saves when relying on Rails' optimistic-locking lifecycle. Do not assume a custom SQL update follows the same conflict-handling contract unless the query explicitly checks and updates lock_version.
Validating JSON Data Without Creating a Fragile Model
For nested documents, I normally validate both the overall shape and the values that affect business logic:
class UserCourse < ApplicationRecord
STATUSES = %w[not_started in_progress completed].freeze
validate :progress_has_expected_shape
private
def progress_has_expected_shape
content = progress["content"]
unless content.is_a?(Hash)
errors.add(:progress, "must contain a content object")
return
end
content.each do |content_id, entry|
unless entry.is_a?(Hash)
errors.add(:progress, "entry #{content_id} must be an object")
next
end
unless STATUSES.include?(entry["status"])
errors.add(:progress, "entry #{content_id} has an invalid status")
end
seconds = entry["progress_in_sec"]
unless seconds.is_a?(Integer) && seconds >= 0
errors.add(:progress, "entry #{content_id} has invalid progress")
end
end
end
endThis validation runs for save, save!, update and update!. It does not run for update_column, update_columns, update_all or raw SQL.
For invariants that must hold regardless of the write path, add a database constraint where practical. Application validation provides helpful error messages; database enforcement protects the data when another script, service or direct SQL statement writes to the table.
Enforcing uniqueness inside JSON
Suppose users.profile.email must be unique. A custom validator that queries existing records can provide a friendly error, but it cannot guarantee uniqueness under concurrent writes. In PostgreSQL, enforce it with a unique expression index:
class AddUniqueProfileEmailIndex < ActiveRecord::Migration[8.1]
def up
execute <<~SQL
CREATE UNIQUE INDEX index_users_on_profile_email
ON users (lower(profile->>'email'))
WHERE profile->>'email' IS NOT NULL;
SQL
end
def down
remove_index :users, name: :index_users_on_profile_email
end
endThe lower expression makes the example case-insensitive. Normalise whitespace and casing consistently before saving so that the index reflects the application's definition of equality.
In MySQL, a common option is a generated column derived from the JSON path with a unique index on that generated value. MySQL also supports functional indexes for suitable JSON expressions, including expressions based on JSON_VALUE in current versions.
Indexing JSON Columns Correctly
The original query pattern should determine the index. Adding a generic index without checking the operators used by the query often creates write overhead without improving reads.
PostgreSQL GIN index for containment and key searches
Use a GIN index when queries search the jsonb document with supported containment, key-existence or JSON-path operators:
class AddProgressGinIndex < ActiveRecord::Migration[8.1]
def change
add_index :user_courses,
:progress,
using: :gin,
name: :index_user_courses_on_progress_gin
end
endExample containment query:
UserCourse.where(
"progress @> ?",
{ content: { "148" => { status: "completed" } } }.to_json
)PostgreSQL B-tree expression index for one scalar path
If the application frequently filters or sorts by one fixed scalar value, index the extracted expression:
class AddProfileCountryIndex < ActiveRecord::Migration[8.1]
def up
execute <<~SQL
CREATE INDEX index_users_on_profile_country
ON users ((profile->>'country'));
SQL
end
def down
remove_index :users, name: :index_users_on_profile_country
end
endUser.where("profile->>'country' = ?", "IN")The extracted value from ->> is text, so this is normally a B-tree expression index. Applying using: :gin to that scalar expression is not the same as placing a GIN index on the jsonb document.
Let’s Build Scalable Apps with Ruby on Rails!
We build robust Ruby on Rails applications that scale smoothly and deliver high performance from day one.
MySQL index for an extracted JSON value
MySQL JSON columns are not indexed directly as ordinary scalar columns. For a frequently queried path, use a compatible functional index or generated column. A functional-index example is:
CREATE INDEX index_users_on_profile_country
ON users ((JSON_VALUE(profile, '$.country' RETURNING CHAR(2))));The expression in the query must match the indexed expression closely enough for the optimizer to use it. Confirm with EXPLAIN rather than assuming the index is active.
How to Choose the Right Update Strategy
| Situation | Recommended approach | Main trade-off |
| Small JSON document with infrequent updates | Copy the hash and call update! | Rewrites the JSON attribute and can lose concurrent changes without locking |
| Stable top-level JSON keys | store_accessor plus normal validation | Does not provide arbitrary nested access or database columns |
| One PostgreSQL path changes frequently | jsonb_set | Database-specific SQL; callbacks and validations are bypassed |
| One or more MySQL paths change frequently | JSON_SET | Database-specific SQL; JSON types need careful handling |
| Update depends on the current document | Transaction with a row lock | Concurrent writers may wait |
| Conflicts are rare and should be detected | Rails optimistic locking | Application must resolve or retry stale writes |
| JSON members behave like independent entities | Move them to a relational table | Requires a schema change and migration |
When JSON Is the Wrong Data Model
JSON is a good fit for flexible metadata, external payloads, settings and document-shaped data that is usually read as a whole. It becomes a warning sign when nested members need their own:
- validations and lifecycle callbacks;
- unique constraints or foreign keys;
- frequent independent updates;
- joins, aggregates or ordering;
- audit history; or
- access-control rules.
In the progress example, a relational model may eventually be clearer:
course_progresses
user_course_id
content_id
status
progress_in_sec
updated_atA unique index on [user_course_id, content_id] gives each content item an independently updateable row. JSON can remain useful for optional metadata that does not need relational behavior.
Tests I Add Before Shipping a JSON Update
A JSON update deserves more than a happy-path model test. I normally verify that:
- the requested path changes;
- sibling keys and unrelated content entries remain unchanged;
- strings, numbers, booleans and JSON
nullretain the intended types; - a missing parent path has defined behavior;
- invalid values are rejected before direct SQL runs;
- callbacks and timestamps behave as expected for the chosen method;
- concurrent updates do not silently overwrite one another; and
EXPLAINshows that production-shaped queries use the intended index.
For database-specific queries, run integration tests against the same database engine used in production. SQLite-based tests will not prove that PostgreSQL jsonb_set, GIN indexes or MySQL JSON_SET behave correctly.
Conclusion
There is no single best method for updating every JSON column in Rails.
For a small document with low write contention, copying the hash and calling update! is clear and keeps Rails validations and callbacks intact. For a frequently updated nested path, PostgreSQL jsonb_set or MySQL JSON_SET avoids the application-level read-modify-write cycle. When the new value depends on the current document, pessimistic or optimistic locking prevents silent lost updates.
The most important decision is often architectural. If nested JSON values need independent validation, querying, indexing and updates, they are probably records rather than document properties. Moving them into a relational table can be simpler than continuing to add increasingly complex JSON operations.
Frequently Asked Questions
Can Rails update only one key inside a JSON column?
Active Record treats the JSON document as one attribute. For a true path-level database update, use PostgreSQL jsonb_set or MySQL JSON_SET, usually through a carefully parameterized SQL assignment that preserves unrelated keys.
Does update_column partially update JSON?
No. update_column bypasses validations and callbacks, but it still assigns a new value to the JSON column. It does not translate a nested Ruby key into a database JSON-path operation.
Should I use PostgreSQL json or jsonb with Rails?
Use jsonb for most queryable application data because it supports PostgreSQL's JSON operators and GIN indexing. Use json only when preserving the original textual representation is specifically required by the application.
How can I prevent concurrent JSON updates from overwriting each other?
Use a single database-level JSON update when possible. For read-modify-write logic, use a transaction with a row lock or Rails optimistic locking with a lock_version column and explicit conflict handling.
Can I validate nested JSON keys in Rails?
Yes. Use store_accessor and normal validators for stable top-level keys. For nested structures, write a custom validator and add database constraints or unique expression indexes to protect critical invariants across every write path.



