countries / countries/countries
Country.translations re-reads and re-parses the locale JSON on every call
Nobody has claimed this yet.
- Dominant language
- Ruby
- Stars
- 2.4k
- Forks
- 682
- Avg merge
- 5d 4h
- Merged PRs (30d)
- 2
Description
(Sorry for the AI generated text but I ran into this during a performance analysis in my app and Claude discovered this issue)
Summary
ISO3166::Country.translations(locale) does a File.read + JSON.parse of
locales/<locale>.json on every invocation, with no memoization.
Country.all_translated delegates to it and is affected too.
By contrast, the instance method Country#translation(locale) reads from the
in-memory ISO3166::Data cache and does no I/O — so the class-level API is thea
inconsistent one.
https://github.com/countries/countries/blob/master/lib/countries/country/class_methods.rb#L60-L68
def translations(locale = :en)
locale = locale.to_sym if locale.is_a?(String)
locale = locale.downcase if locale.match?(/[A-Z]/)
file_path = ISO3166::Data.datafile_path(%W[locales #{locale}.json])
translations = JSON.parse(File.read(file_path))
translations.merge(custom_countries_translations(locale))
end
Reproduction
require 'countries'
$reads = 0
File.singleton_class.prepend(Module.new do
def read(*a, **k); $reads += 1 if a.first.to_s.end_with?('.json'); super; end
end)
ISO3166::Country.translations(:en) # warm
$reads = 0
100.times { ISO3166::Country.translations(:en) }
puts "file reads, 100x translations(:en): #{$reads}"
$reads = 0
100.times { ISO3166::Country.all.map { |c| c.translation(:en) } }
puts "file reads, 100x Country#translation: #{$reads}"
Results
file reads, 100x translations(:en): 100
file reads, 100x Country#translation: 0
translations(:en) 0.0547 ms/call
all_translated(:en) 0.0484 ms/call
en.json: 4636 bytes, 249 entries
Why it matters
Building a country select list is a common use of this API. In our app the call
sits in an object that is constructed per request, so every page render does a
filesystem read and a 4.6 KB JSON parse to produce a list that cannot change
within the process. The per-call cost is small, but it is unbounded in call
count and it is I/O rather than CPU.
Callers can memoize it themselves (we now do), so this isn't blocking us. The
surprising part is that the class-level API does I/O while the instance-level
equivalent is cached.
Notes on a possible fix
Memoizing isn't a one-liner, so rather than assume, some tradeoffs worth
deciding first:
- Mutation.
translationscurrently returns a fresh, unfrozenHashon
every call. Memoizing and returning the same object would let one caller's
mutation leak into every other caller. Options are to freeze the cached hash
(a breaking change for anyone mutating the result) or todupon return
(still avoids the read + parse, and is cheaper than today). - Invalidation. The result merges
custom_countries_translations(locale),
so the cache would need clearing onISO3166::Data.register/unregister
/reset. - Thread safety.
ISO3166::Data.synchronizedalready exists to build on.
Happy to open a PR if you have a preference on the mutation question
(freeze vs. dup).
Environment
- countries 8.1.0 (latest release;
masteris identical as of 2026-09-09) - Ruby 4.0.6
- Linux
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in lib/countries/country/class_methods.rb at translations, then inspect ISO3166::Data.synchronized and the register, unregister, and reset paths mentioned in the issue. Reproduce the repeated reads with the provided script. Done means repeated translations calls avoid unnecessary file reads while preserving the stated mutation, invalidation, and thread-safety behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- ruby
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100