|
| 1 | +--- |
| 2 | +title: "Before upgrading to Rails 8, update to JSON 2.14.0 to avoid being hit by strings vs symbols" |
| 3 | +created_at: 2025-09-25 |
| 4 | +author: Piotr Jurewicz |
| 5 | +tags: ['rails', 'ruby', 'json', 'rails upgrade'] |
| 6 | +publish: false |
| 7 | +--- |
| 8 | + |
| 9 | +# Before upgrading to Rails 8, update to JSON 2.14.0 to avoid being hit by strings vs symbols |
| 10 | + |
| 11 | +The upgrade from **Rails 7.2.2.2 to 8.0.2.1** went surprisingly smoothly. |
| 12 | +After deployment, we didn’t notice any new exceptions, and the application seemed stable. |
| 13 | +At least at first... |
| 14 | + |
| 15 | +## First reports |
| 16 | + |
| 17 | +After a while, we started receiving complaints from an external application consuming our JSON API. |
| 18 | +Identifiers that were supposed to be **strings** suddenly started arriving as **integers**. 🤔 |
| 19 | + |
| 20 | +We rolled back the changes and began debugging. |
| 21 | + |
| 22 | +## The suspicious line |
| 23 | + |
| 24 | +It turned out the problem originated in the code responsible for serializing an ActiveRecord object. |
| 25 | +We had something like this: |
| 26 | + |
| 27 | +```ruby |
| 28 | +attributes.merge(id: public_id) |
| 29 | +``` |
| 30 | + |
| 31 | +The intention was simple: replace the primary key with a public identifier used for inter-service communication. |
| 32 | + |
| 33 | +The problem? `attributes` returns a hash with **string keys**, and we were merging in a value under a **symbol key**. |
| 34 | +The result was a hash with both keys: |
| 35 | + |
| 36 | +```ruby |
| 37 | +{ "id" => 1, :id => "one" } |
| 38 | +``` |
| 39 | + |
| 40 | +Up until Rails 7.2, this wasn’t a big deal. |
| 41 | +When the controller executed: |
| 42 | + |
| 43 | +```ruby |
| 44 | +render json: { "id" => 1, :id => "one" } |
| 45 | +``` |
| 46 | + |
| 47 | +Rails would internally call `as_json`, which deduplicated keys. The final JSON always used the last provided value under the string key. |
| 48 | + |
| 49 | +## What changed in Rails 8? |
| 50 | + |
| 51 | +Rails 8 introduced [this optimization](https://github.com/rails/rails/commit/42d75ed3a8b96ee4610601ecde7c40e9d65e003f) combined with [another one from Rails 7.1](https://github.com/rails/rails/pull/48614/commits/66db67436d3b7bcdf63e8295adb7c737f76844ad#diff-c202bc84686ddd83549f9603008d8fb9f394a05e76393ff160b7c9494165fc4a). |
| 52 | + |
| 53 | +Both changes were performance-driven: |
| 54 | + |
| 55 | +* **Rails 7.1 PR (#48614)** — optimized `render json:` by avoiding unnecessary calls to `as_json` on hashes that were already in a suitable format. The idea was to save work when serializing hashes, especially large ones, since calling `as_json` for every nested value introduced overhead. |
| 56 | +* **Rails 8 commit (42d75ed3a)** — went further and skipped even more redundant conversions by directly passing through hashes to the JSON encoder whenever possible. Again, the goal was reducing allocations and method dispatch during rendering. |
| 57 | + |
| 58 | +Together, these optimizations meant that in many cases Rails stopped normalizing keys through `as_json`. |
| 59 | +That shaved off some cycles, but in our case it exposed the subtle bug with mixed string/symbol keys. |
| 60 | + |
| 61 | +As a result, we ended up sending JSON with **duplicate keys**: |
| 62 | + |
| 63 | +```json |
| 64 | +{"id":1,"id":"one"} |
| 65 | +``` |
| 66 | + |
| 67 | +That’s exactly what broke our consumer. |
| 68 | + |
| 69 | +## The changelog confusion |
| 70 | + |
| 71 | +Interestingly, the [Rails 7.1.3 changelog](https://github.com/rails/rails/blob/main/activemodel/CHANGELOG.md#rails-713) claimed: |
| 72 | + |
| 73 | +``` |
| 74 | +Fix `ActiveSupport::JSON.encode` to prevent duplicate keys. |
| 75 | +
|
| 76 | + If the same key exist in both String and Symbol form it could |
| 77 | + lead to the same key being emitted twice. |
| 78 | +``` |
| 79 | + |
| 80 | +This gave the impression that duplicates would never occur. |
| 81 | +Unfortunately, that fix was reverted before release — the changelog was misleading. |
| 82 | +We ended up creating a [PR to correct it](https://github.com/rails/rails/pull/55735). |
| 83 | + |
| 84 | +## Guarding yourself before the upgrade |
| 85 | + |
| 86 | +All of this could have been avoided if we had upgraded the `json` gem to **2.14.0** beforehand. |
| 87 | +That version introduced stricter handling of duplicate keys: |
| 88 | + |
| 89 | +> **Add new `allow_duplicate_key` generator option.** |
| 90 | +> By default a warning is now emitted when a duplicated key is encountered. |
| 91 | +> In JSON 3.0 this will raise an error. |
| 92 | +
|
| 93 | +Example: |
| 94 | + |
| 95 | +```ruby |
| 96 | +Warning[:deprecated] = true |
| 97 | + |
| 98 | +puts JSON.generate({ foo: 1, "foo" => 2 }) |
| 99 | +# (irb):2: warning: detected duplicate key "foo" in {foo: 1, "foo" => 2}. |
| 100 | +# {"foo":1,"foo":2} |
| 101 | + |
| 102 | +JSON.generate({ foo: 1, "foo" => 2 }, allow_duplicate_key: false) |
| 103 | +# JSON::GeneratorError: detected duplicate key "foo" in {foo: 1, "foo" => 2} |
| 104 | +``` |
| 105 | + |
| 106 | +If we had been on `json >= 2.14.0`, we would have seen deprecation warnings during testing — long before this issue made it into production. |
| 107 | + |
| 108 | +We actively monitor Ruby deprecation warnings (I wrote a separate post on that [here](https://blog.arkency.com/do-you-tune-out-ruby-deprecation-warnings/)). |
| 109 | +Had JSON 2.14.0 been available at the time of our upgrade, we might have spotted this regression earlier. |
| 110 | +Unfortunately, the release came out just a week after we finished our upgrade. |
| 111 | + |
| 112 | +Even if you’re not yet on `json >= 2.14.0`, there’s a way to guard against this kind of bug during development and testing. |
| 113 | +Rails allows you to treat specific deprecations as **disallowed** — and raise an exception whenever they occur. |
| 114 | + |
| 115 | +By adding the following to your environment configuration (e.g. `config/environments/test.rb`): |
| 116 | + |
| 117 | +```ruby |
| 118 | +config.active_support.disallowed_deprecation_warnings = [/detected duplicate key/] |
| 119 | +config.active_support.disallowed_deprecation = :raise |
| 120 | +``` |
| 121 | + |
| 122 | +You turn the `"detected duplicate key"` warning into a **hard error**. |
| 123 | + |
| 124 | +This way, if your automated test suite — or even manual QA runs — ever trigger rendering of JSON with duplicate keys, the test will fail immediately. |
| 125 | +Much better to catch it there than discover it from an angry API consumer in production. 🚨 |
| 126 | + |
| 127 | +--- |
| 128 | + |
| 129 | +### Takeaway |
| 130 | + |
| 131 | +Before jumping to Rails 8, **make sure your project depends on `json >= 2.14.0`**. |
| 132 | +It will warn you about duplicate keys, helping you avoid subtle, hard-to-debug issues with string vs symbol hash keys sneaking into your JSON API. |
| 133 | + |
| 134 | +Happy upgrading 🚀 |
0 commit comments