Skip to content

Commit 08c82ea

Browse files
committed
change 2 chapters
1 parent a7f8388 commit 08c82ea

File tree

1 file changed

+26
-44
lines changed

1 file changed

+26
-44
lines changed

posts/2025-09-25-duplicate-keys-sneaking-into-our-JSON-responses.md

Lines changed: 26 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ We had something like this:
2727
attributes.merge(id: public_id)
2828
```
2929

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.
3131

3232
The problem? `attributes` returns a hash with **string keys**, and we were merging in a value under a **symbol key**.
3333
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
4747

4848
## What changed in Rails 8?
4949

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.
6155
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.
6459
```
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**.
6565

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.
6768

6869
## The changelog confusion
6970

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

7273
```
7374
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
8283

8384
## Guarding yourself before the upgrade
8485

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.
8788

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.
9191

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

9494
```ruby
9595
Warning[:deprecated] = true
9696

9797
puts JSON.generate({ foo: 1, "foo" => 2 })
9898
# (irb):2: warning: detected duplicate key "foo" in {foo: 1, "foo" => 2}.
9999
# {"foo":1,"foo":2}
100-
101-
JSON.generate({ foo: 1, "foo" => 2 }, allow_duplicate_key: false)
102-
# JSON::GeneratorError: detected duplicate key "foo" in {foo: 1, "foo" => 2}
103100
```
104101

105-
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).
106103

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

116106
```ruby
117107
config.active_support.disallowed_deprecation_warnings = [/detected duplicate key/]
118108
config.active_support.disallowed_deprecation = :raise
119109
```
120110

121-
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.
129112

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.
132114

133115
Happy upgrading 🚀

0 commit comments

Comments
 (0)