You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: posts/2025-09-25-duplicate-keys-sneaking-into-our-JSON-responses.md
+26-44Lines changed: 26 additions & 44 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -27,7 +27,7 @@ We had something like this:
27
27
attributes.merge(id: public_id)
28
28
```
29
29
30
-
The intention was simple: replace the private database ID with a public identifier used for inter-service communication.
30
+
The intention was simple: replace the foreign key with a public identifier used for inter-service communication.
31
31
32
32
The problem? `attributes` returns a hash with **string keys**, and we were merging in a value under a **symbol key**.
33
33
The result was a hash with both keys:
@@ -47,27 +47,28 @@ Rails would internally call `as_json`, which deduplicated keys. The final JSON a
47
47
48
48
## What changed in Rails 8?
49
49
50
-
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).
51
-
52
-
Both changes were performance-driven:
53
-
54
-
***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.
55
-
***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.
56
-
57
-
Together, these optimizations meant that in many cases Rails stopped normalizing keys through `as_json`.
58
-
That shaved off some cycles, but in our case it exposed the subtle bug with mixed string/symbol keys.
59
-
60
-
As a result, we ended up sending JSON with **duplicate keys**:
50
+
The real change came from this [Rails commit](https://github.com/rails/rails/commit/42d75ed3a8b96ee4610601ecde7c40e9d65e003f), which says:
51
+
```
52
+
Only add template options when rendering template
53
+
...
54
+
This commit avoids adding those keys unless we are rendering a template.
61
55
62
-
```json
63
-
{"id":1,"id":"one"}
56
+
This improves performance both by avoiding calculating the templates to
57
+
put into this options hash as well as enabling a fast path in `render json:`,
58
+
which can only be used when `.to_json` is given no options.
64
59
```
60
+
That **fast path** made JSON rendering faster — but with one important side effect:
61
+
it no longer invoked `as_json`, which quietly normalized keys — turning symbol keys into strings.
62
+
In our case, that invisible normalization prevented the duplicate key problem.
63
+
64
+
With Rails 8, the fast path skipped [this line](https://github.com/rails/rails/blob/c3ad0afaa8045da0f420a0b25bdf0d38da614e61/activesupport/lib/active_support/json/encoding.rb#L57). Our mixed-key hash (`{"id" => 1, :id => "one"}`) stayed exactly as it was, and the JSON encoder output a response with **duplicate keys**.
65
65
66
-
That’s exactly what broke our consumer.
66
+
So the root cause was in our code, but for years we were unknowingly relying on Rails’ implicit key normalization.
67
+
Once that disappeared, the bug became visible.
67
68
68
69
## The changelog confusion
69
70
70
-
Interestingly, the [Rails 7.1.3 changelog](https://github.com/rails/rails/blob/main/activemodel/CHANGELOG.md#rails-713) claimed:
71
+
Interestingly, the [Rails 7.1.3 changelog](https://github.com/rails/rails/blob/7-1-stable/activesupport/CHANGELOG.md#rails-713-january-16-2024) claimed:
71
72
72
73
```
73
74
Fix `ActiveSupport::JSON.encode` to prevent duplicate keys.
@@ -82,52 +83,33 @@ We ended up creating a [PR to correct it](https://github.com/rails/rails/pull/55
82
83
83
84
## Guarding yourself before the upgrade
84
85
85
-
All of this could have been avoided if we had upgraded the `json` gem to **2.14.0**beforehand.
86
-
That version introduced stricter handling of duplicate keys:
86
+
The most reliable way to catch this kind of regression is to have **request specs**that assert on the exact JSON response body.
87
+
If you test for the precise shape of the payload, duplicate keys will immediately surface as a mismatch.
87
88
88
-
> **Add new `allow_duplicate_key` generator option.**
89
-
> By default a warning is now emitted when a duplicated key is encountered.
90
-
> In JSON 3.0 this will raise an error.
89
+
But let’s be honest: most projects don’t have 100% coverage of every single controller action at that level of detail.
90
+
And that’s where the ecosystem itself can help.
91
91
92
-
Example:
92
+
Starting from `json 2.14.0`, the library emits a warning whenever a hash with both string and symbol versions of the same key is encoded:
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.
102
+
In JSON 3.0 this will go even further and raise an error by default (unless you explicitly allow duplicates).
106
103
107
-
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/)).
108
-
Had JSON 2.14.0 been available at the time of our upgrade, we might have spotted this regression earlier.
109
-
Unfortunately, the release came out just a week after we finished our upgrade.
110
-
111
-
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.
112
-
Rails allows you to treat specific deprecations as **disallowed** — and raise an exception whenever they occur.
113
-
114
-
By adding the following to your environment configuration (e.g. `config/environments/test.rb`):
104
+
You can also surface such warnings in your Rails test or development environments by treating them as disallowed deprecations:
You turn the `"detected duplicate key"` warning into a **hard error**.
122
-
123
-
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.
124
-
Much better to catch it there than discover it from an angry API consumer in production. 🚨
125
-
126
-
---
127
-
128
-
### Takeaway
111
+
This way, even if you don’t assert every response body in detail, there’s still a good chance your automated or manual tests will trip over a duplicate key and fail early.
129
112
130
-
Before jumping to Rails 8, **make sure your project depends on `json >= 2.14.0`**.
131
-
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.
113
+
It’s not a silver bullet — but it’s a lightweight safeguard that makes it much less likely duplicate keys sneak into your JSON responses unnoticed.
0 commit comments