bkeepers/dotenv · error · ThreadError

Dotenv.restore is not thread safe. Use `Dotenv.modify { }` t

Error message

Dotenv.restore is not thread safe. Use `Dotenv.modify { }` to update ENV for the duration of the block in a thread safe manner, or call `Dotenv.restore(safe: true)` to ignore this error.

What it means

Dotenv.restore replaces the entire process-global ENV with a saved snapshot via ENV.replace, which can corrupt ENV for concurrently running threads. Because the default `safe:` argument is `Thread.current == Thread.main`, dotenv raises ThreadError whenever restore is called from any thread other than the main one. The error message points you to Dotenv.modify { }, which performs the update and restore inside a semaphore, or to explicitly opt in with `safe: true` when you know no other thread depends on ENV.

Source

Thrown at lib/dotenv.rb:85

  def save
    instrument(:save) do |payload|
      @diff = payload[:diff] = Dotenv::Diff.new
    end
  end

  # Restore `ENV` to a given state
  #
  # @param env [Hash] Hash of keys and values to restore, defaults to the last saved state
  # @param safe [Boolean] Is it safe to modify `ENV`? Defaults to `true` in the main thread, otherwise raises an error.
  def restore(env = @diff&.a, safe: Thread.current == Thread.main)
    # No previously saved or provided state to restore
    return unless env

    diff = Dotenv::Diff.new(b: env)
    return unless diff.any?

    unless safe
      raise ThreadError, <<~EOE.tr("\n", " ")
        Dotenv.restore is not thread safe. Use `Dotenv.modify { }` to update ENV for the duration
        of the block in a thread safe manner, or call `Dotenv.restore(safe: true)` to ignore
        this error.
      EOE
    end
    instrument(:restore, diff: diff) { ENV.replace(env) }
  end

  # Update `ENV` with the given hash of keys and values
  #
  # @param env [Hash] Hash of keys and values to set in `ENV`
  # @param overwrite [Boolean|:warn] Overwrite existing `ENV` values
  def update(env = {}, overwrite: false)
    instrument(:update) do |payload|
      diff = payload[:diff] = Dotenv::Diff.new do
        ENV.update(env.transform_keys(&:to_s)) do |key, old_value, new_value|
          # This block is called when a key exists. Return the new value if overwrite is true.
          case overwrite

View on GitHub (pinned to 34156bf400)

Solutions

  1. Replace the save/restore pattern with `Dotenv.modify { }`, which sets ENV for the block and restores it under a lock
  2. If you have verified nothing else reads or writes ENV concurrently, opt in explicitly with `Dotenv.restore(state, safe: true)`
  3. Restructure so the restore runs on the main thread (e.g. defer it to a queue drained at boot or between jobs)

Example fix

# before
snapshot = Dotenv::Diff.new.a
Thread.new do
  Dotenv.restore(snapshot) # raises ThreadError
end

# after
Thread.new do
  Dotenv.modify("API_KEY" => "temp") do
    # ENV is updated here and restored automatically, thread-safely
  end
end
Defensive patterns

Strategy: validation

Validate before calling

saved = Dotenv::Diff.new.a
Dotenv.restore(saved) if Thread.current == Thread.main

Try / catch

begin
  Dotenv.restore(snapshot)
rescue ThreadError
  # ENV is process-global; defer the mutation to the main thread
  main_thread_queue << -> { Dotenv.restore(snapshot, safe: true) }
end

Prevention

When it happens

Trigger: Calling `Dotenv.restore` (no `safe:` argument) from a non-main thread: inside Sidekiq/Puma worker threads, Concurrent::Ruby tasks, RSpec threads, or test teardown hooks that snapshot/restore ENV while running under parallelized or threaded test runners.

Common situations: Test suites upgraded to dotenv 3.x that call restore in after-hooks executing off the main thread; background jobs that reset ENV after temporarily setting keys; code that saved state with a Diff on the main thread but restores it from a worker thread.

Related errors


AI-assisted analysis of bkeepers/dotenv@34156bf400 (2026-08-21). Data as JSON: /api/errors/af54944b097c94dc. Report an issue: GitHub.