Skip to content

Commit b1e42a2

Browse files
committed
draft
1 parent 927cacc commit b1e42a2

File tree

1 file changed

+134
-0
lines changed

1 file changed

+134
-0
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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

Comments
 (0)