#58796·rails

`upsert_all` writes default scope attributes into the UPDATE clause

Author: sucicfilipCreated Sep 17, 2026Updated Sep 17, 2026

Steps to reproduce

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  gem "rails"
  # If you want to test against edge Rails replace the previous line with this:
  # gem "rails", github: "rails/rails", branch: "main"

  gem "sqlite3"
end

require "active_record/railtie"
require "minitest/autorun"

ENV["DATABASE_URL"] = "sqlite3::memory:"

class TestApp < Rails::Application
  config.load_defaults Rails::VERSION::STRING.to_f
  config.eager_load = false
  config.logger = Logger.new($stdout)
  config.secret_key_base = "secret_key_base"
end
Rails.application.initialize!

ActiveRecord::Schema.define do
  create_table :posts, force: true do |t|
    t.string :title
    t.datetime :deleted_at
  end
end

class Post < ActiveRecord::Base
  default_scope { where(deleted_at: nil) }
end

class BugTest < ActiveSupport::TestCase
  def test_upsert_all_does_not_write_the_default_scope_column
    ActiveRecord::Base.connection.execute(
      "INSERT INTO posts (id, title, deleted_at) VALUES (1, 'old', '2020-01-01 00:00:00')"
    )

    Post.upsert_all([{ id: 1, title: "new" }])

    post = Post.unscoped.find(1)

    assert_equal "new", post.title
    assert_not_nil post.deleted_at
  end
end

Expected behavior

The upsert updates title and leaves deleted_at alone. Nothing in the payload mentions deleted_at.

Actual behavior

The soft deleted row is restored:

INSERT INTO "posts" ("id","title","deleted_at") VALUES (1, 'new', NULL)
ON CONFLICT ("id") DO UPDATE SET "title"=excluded."title","deleted_at"=excluded."deleted_at"

InsertAll#initialize merges relation.scope_for_create into @keys, and updatable_columns is keys - readonly_columns - unique_by_columns, so the scope keys survive into the SET list the adapters build.

On the INSERT branch that matches documented behaviour (Article.new.published # => true). On the UPDATE branch it contradicts it, since default_scope "is not applied while updating or deleting a record" (active_record/scoping/default.rb). update_all on the same relation puts the scope in WHERE and never in SET, and neither save nor update_columns touches the column.

This is not a regression, I get the same SQL on 6.1.7.9, 7.0.8.5 and 8.1.3.1. The old if model.scope_attributes? guard reads like it excluded default scopes, but Scoping::Default::ClassMethods#scope_attributes? is super || default_scopes.any?, so a declared default_scope always satisfied it.

update_only: works around it. A fix needs both halves: dropping from updatable_columns the scope keys the caller did not pass, and the precedence change in #44316 so that a payload built from record.as_json, which does carry the column, keeps its own value. Either half alone still leaves one of the two payload shapes broken. Association keys need to stay an exception and keep being forced onto every row, the way Associations::Association#initialize_attributes forces them, otherwise author.books.upsert_all stops stamping author_id. I have that patched and will open a PR.

System configuration

Rails version: 8.1.3.1, also reproduced on 7.0.8.5 and 6.1.7.9

Ruby version: 3.4.7