Blogs/Technology

How to Update JSON Columns in Ruby on Rails (2026)

Written byPiyush Duragkar
Aug 17, 2026
10 Min Read
How to Update JSON Columns in Ruby on Rails (2026) Hero
Too Long? Read This First

- 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
end

For MySQL, use its native json column type:

class AddProgressToUserCourses < ActiveRecord::Migration[8.1]
  def change
    add_column :user_courses, :progress, :json, null: false
  end
end

If 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 }
end

You 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)
end

Other 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
end

Rails 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
end

This 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
end

The 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
end

Example 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
end
User.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

SituationRecommended approachMain trade-off
Small JSON document with infrequent updatesCopy the hash and call update!Rewrites the JSON attribute and can lose concurrent changes without locking
Stable top-level JSON keysstore_accessor plus normal validationDoes not provide arbitrary nested access or database columns
One PostgreSQL path changes frequentlyjsonb_setDatabase-specific SQL; callbacks and validations are bypassed
One or more MySQL paths change frequentlyJSON_SETDatabase-specific SQL; JSON types need careful handling
Update depends on the current documentTransaction with a row lockConcurrent writers may wait
Conflicts are rare and should be detectedRails optimistic lockingApplication must resolve or retry stale writes
JSON members behave like independent entitiesMove them to a relational tableRequires a schema change and migration
Small JSON document with infrequent updates
Recommended approach
Copy the hash and call update!
Main trade-off
Rewrites the JSON attribute and can lose concurrent changes without locking
1 of 7

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_at

A 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:

  1. the requested path changes;
  2. sibling keys and unrelated content entries remain unchanged;
  3. strings, numbers, booleans and JSON null retain the intended types;
  4. a missing parent path has defined behavior;
  5. invalid values are rejected before direct SQL runs;
  6. callbacks and timestamps behave as expected for the chosen method;
  7. concurrent updates do not silently overwrite one another; and
  8. EXPLAIN shows 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.

Author-Piyush Duragkar
Piyush Duragkar

Backend Developer with 3+years of experience with Ruby on Rails. Specialising in backend optimisation, API development, and database. Enthusiastic about developing scalable, effective solutions.

Share this article

Phone

Next for you

8 Best GraphQL Libraries for Node.js in 2025 Cover

Technology

Aug 4, 202613 min read

8 Best GraphQL Libraries for Node.js in 2025

8 Best GraphQL Libraries for Node.js in 2026 Too Long? Read This First - Choose Apollo Server when you need a mature ecosystem, GraphOS integration, plugins, or Apollo Federation. - Choose GraphQL Yoga for a modern, portable server with Fetch API compatibility and built-in support for subscriptions over Server-Sent Events. - Choose Mercurius when your application already uses Fastify and runtime efficiency is a major priority. - Use GraphQL.js when you need the official JavaScript implementati

9 React Native Animation Libraries and Tools Compared Cover

Technology

Aug 4, 202615 min read

9 React Native Animation Libraries and Tools Compared

Too Long? Read This First - Use React Native Reanimated for gesture-driven, interruptible, and performance-sensitive interface animations. - Use the built-in Animated API for simple fades, transforms, and timed sequences without another dependency. - Pair React Native Gesture Handler with Reanimated for swipes, dragging, pinching, rotation, and other touch-driven experiences. - Use Lottie React Native for non-interactive motion graphics supplied by designers. - Choose React Native Skia for cust

9 Critical Practices for Secure Web Application Development Cover

Technology

Aug 4, 202616 min read

9 Critical Practices for Secure Web Application Development

Too Long? Read This First - Define security requirements and model threats before implementation begins. - Treat authentication, account recovery, and MFA as one complete identity system. - Apply server-side authorization to every protected action and object. - Prevent injection with parameterized APIs, structured validation, safe output handling, and restricted outbound requests. - Protect sessions and tokens throughout their complete lifecycle. - Minimise sensitive data and manage encryption