🚧

create_or_find_byの注意点

に公開

もはや暑い以外の言葉が見つからずクーラーという反転術式を覚えたい今日この頃です。

さて本日はRails 5.1.5から導入されてたと言われているcreate_or_find_byを用いる際の注意点をシェアハピしていきたいと思っています。(ほぼコードコメントに書かれていることです...)

https://railsdoc.com/page/create_or_find_by

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation.rb#L209-L213

create_or_find_byとは

元々あったfind_or_create_byでは、ほぼ同時にリクエストがあった場合にSELECT->INSERTを行うためvalidationエラーになってしまう問題がありました。

コードコメントにも書かれています。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation.rb#L162-L165

そこで追加されたのが今回取り上げているcreate_or_find_byです。
INSERT->SELECTで実行されるためほぼ同時にリクエストがあった場合に先にINSERTが実行された行をSELECTするようになっています。
実装的にはcreate()を実行した後にActiveRecord::RecordNotUniqueをrescueしています。

注意点

先にざっと自分が感じたcreate_or_find_byを使う際の注意点を挙げます。

  • DBレベルでUNIQUE制約をつけておく
  • ActiveRecord::RecordNotFoundのハンドリングを考慮する
  • rescueによるハンドリングをしているため若干重い
  • INSERT->SELECTのコンフリクトの可能性
  • modelのvalidationをかけている場合は上手く動かないことがある

さてそれぞれの注意点を解説していきます!!

DBレベルでUNIQUE制約をつけておく

これが一番重要でコードコメントにもしっかり書かれています。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation.rb#L191-L194

ActiveRecord::RecordNotUniqueをrescueするとfind_byが実行される実装になっているためですね。
実際にUNIQUE制約をつけた場合とつけなかった場合にそれぞれどのようになるか見てみましょう。

UNIQUE制約をつけなかった場合

schema.rb
  create_table "hoges", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
    t.string "uuid", null: false
    t.string "name", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["uuid"], name: "index_hoges_on_uuid"
  end
# sandoxモード使っています
b23bf9d416ff:/app# ./bin/rails c -s
Any modifications you make will be rolled back on exit
shogun(dev)> uuid = SecureRandom.uuid
=> "9f507919-ada5-4a80-9932-6f733d3563c8"
shogun(dev)* hoge1 = Hoge.create_or_find_by(uuid: uuid) do |h|
shogun(dev)*   h.name = 'fuga'
shogun(dev)> end
  TRANSACTION (0.1ms)  BEGIN
  TRANSACTION (0.2ms)  SAVEPOINT active_record_1
  Hoge Create (0.4ms)  INSERT INTO `hoges` (`uuid`, `name`, `created_at`, `updated_at`) VALUES ('9f507919-ada5-4a80-9932-6f733d3563c8', 'fuga', '2025-08-01 04:19:53.635865', '2025-08-01 04:19:53.635865')
  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
=> #<Hoge:0x0000ffff7de44bc0 id: 1, uuid: "9f507919-ada5-4a80-9932-6f733d3563c8", name: "fuga", created_at: "2025-08-01 13:19:53.635865000 +0900", updated_at: "2025-08-01 13:19:53.635865000 +0900">
shogun(dev)* hoge2 = Hoge.create_or_find_by(uuid: uuid) do |h|
shogun(dev)*   h.name = 'piyo'
shogun(dev)> end
  TRANSACTION (0.6ms)  SAVEPOINT active_record_1
  TRANSACTION (10.2ms)  SAVEPOINT active_record_2
  Hoge Create (30.8ms)  INSERT INTO `hoges` (`uuid`, `name`, `created_at`, `updated_at`) VALUES ('9f507919-ada5-4a80-9932-6f733d3563c8', 'piyo', '2025-08-01 04:20:15.619954', '2025-08-01 04:20:15.619954')
  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_2
=> #<Hoge:0x0000ffff7da56400 id: 2, uuid: "9f507919-ada5-4a80-9932-6f733d3563c8", name: "piyo", created_at: "2025-08-01 13:20:15.619954000 +0900", updated_at: "2025-08-01 13:20:15.619954000 +0900">
shogun(dev)> Hogeg.all
(shogun):10:in '<main>': uninitialized constant Hogeg (NameError)
shogun(dev)> Hoge.all
  TRANSACTION (0.5ms)  SAVEPOINT active_record_2
  Hoge Load (1.3ms)  SELECT `hoges`.* FROM `hoges` /* loading for pp */ LIMIT 11
=> 
[#<Hoge:0x0000ffff7fd5c940 id: 1, uuid: "9f507919-ada5-4a80-9932-6f733d3563c8", name: "fuga", created_at: "2025-08-01 13:19:53.635865000 +0900", updated_at: "2025-08-01 13:19:53.635865000 +0900">,
 #<Hoge:0x0000ffff7fd5c800 id: 2, uuid: "9f507919-ada5-4a80-9932-6f733d3563c8", name: "piyo", created_at: "2025-08-01 13:20:15.619954000 +0900", updated_at: "2025-08-01 13:20:15.619954000 +0900">]

UNIQUE制約をつけていない場合はActiveRecord::RecordNotUniqueがraiseされないため2行できてしまいますね。
find_or_create_byの場合は渡されたattributesでSELECTを実行するためUNIQUE制約がない場合でも1行になります!!

UNIQUE制約をつけた場合

schema.rb
  create_table "hoges", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
    t.string "uuid", null: false
    t.string "name", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["uuid"], name: "index_hoges_on_uuid", unique: true
  end
b23bf9d416ff:/app# ./bin/rails c -s
Any modifications you make will be rolled back on exit
shogun(dev)> uuid = SecureRandom.uuid
=> "69f8b394-9de6-426b-a432-9b0eb7e59616"
shogun(dev)* hoge1 = Hoge.create_or_find_by(uuid: uuid) do |h|
shogun(dev)*   h.name = 'fuga'
shogun(dev)> end
  TRANSACTION (0.2ms)  BEGIN
  TRANSACTION (0.2ms)  SAVEPOINT active_record_1
  Hoge Create (0.5ms)  INSERT INTO `hoges` (`uuid`, `name`, `created_at`, `updated_at`) VALUES ('69f8b394-9de6-426b-a432-9b0eb7e59616', 'fuga', '2025-08-01 04:25:01.810012', '2025-08-01 04:25:01.810012')
  TRANSACTION (0.2ms)  RELEASE SAVEPOINT active_record_1
=> #<Hoge:0x0000ffff97a49a38 id: 4, uuid: "69f8b394-9de6-426b-a432-9b0eb7e59616", name: "fuga", created_at: "2025-08-01 13:25:01.810012000 +0900", updated_at: "2025-08-01 13:25:01.810012000 +0900">
shogun(dev)* hoge2 = Hoge.create_or_find_by(uuid: uuid) do |h|
shogun(dev)*   h.name = 'piyo'
shogun(dev)> end
  TRANSACTION (0.3ms)  SAVEPOINT active_record_1
  TRANSACTION (7.4ms)  SAVEPOINT active_record_2
  Hoge Create (25.3ms)  INSERT INTO `hoges` (`uuid`, `name`, `created_at`, `updated_at`) VALUES ('69f8b394-9de6-426b-a432-9b0eb7e59616', 'piyo', '2025-08-01 04:25:15.762897', '2025-08-01 04:25:15.762897')
  TRANSACTION (0.1ms)  ROLLBACK TO SAVEPOINT active_record_2
  Hoge Load (0.5ms)  SELECT `hoges`.* FROM `hoges` WHERE `hoges`.`uuid` = '69f8b394-9de6-426b-a432-9b0eb7e59616' AND `hoges`.`uuid` = '69f8b394-9de6-426b-a432-9b0eb7e59616' LIMIT 1 FOR UPDATE
=> #<Hoge:0x0000ffff996a6e10 id: 4, uuid: "69f8b394-9de6-426b-a432-9b0eb7e59616", name: "fuga", created_at: "2025-08-01 13:25:01.810012000 +0900", updated_at: "2025-08-01 13:25:01.810012000 +0900">
shogun(dev)> Hoge.all
  TRANSACTION (0.4ms)  SAVEPOINT active_record_2
  Hoge Load (1.0ms)  SELECT `hoges`.* FROM `hoges` /* loading for pp */ LIMIT 11
=> [#<Hoge:0x0000ffff996a0290 id: 4, uuid: "69f8b394-9de6-426b-a432-9b0eb7e59616", name: "fuga", created_at: "2025-08-01 13:25:01.810012000 +0900", updated_at: "2025-08-01 13:25:01.810012000 +0900">]

UNIQUE制約をつけるとINSERTを実行した後ロールバックを行いSELECTを実行していることが分かりますね。
以下のようにcreate_or_find_byに渡すattributesが複数の場合は複合UNIQUE制約をつける必要があるので注意してください。

Hoge.create_or_find_by(uuid: uuid, name: 'fuga') # この場合はuuidとnameの複合UNIQUE制約にする必要がある

ActiveRecord::RecordNotFoundのハンドリングを考慮する

失敗時に例外をraiseして欲しい際には!をつけたcreate_or_find_by!を使うと思います。
find_or_create_by!の場合は実装が以下になっています。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation.rb#L175-L177

ここではfind_by!ではないためActiveRecord::RecordNotFoundの例外を呼び出し側で考慮する必要はありません。(実装上当たり前)

create_or_find_by!の場合の実装は以下です。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation.rb#L218-L222

INSERT->ROLLBACK->SELECTなのでfind_by!が呼ばれています。
find_by!だとActiveRecord::RecordNotFoundがraiseされる可能性があります。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation/finder_methods.rb#L85

create_or_find_by!を呼ぶ際にはActiveRecord::RecordNotFoundがraiseされる可能性も考慮し呼び出しましょう!!

rescueによるハンドリングをしているため若干重い

これはコードコメントにも書かれています。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation.rb#L199

スペックにもよりますがfind_or_create_byとどのくらい差分があるか見てみましょう。
benchmarkを使いたいので以下の関数を用意しました。

def find_or_create_by_uuid(uuid, name)
  Hoge.find_or_create_by(uuid: uuid) do |h|
    h.name = name
  end
end

def create_or_find_by_uuid(uuid, name)
  Hoge.create_or_find_by(uuid: uuid) do |h|
    h.name = name
  end
end

find_or_create_byの場合

b23bf9d416ff:/app# ./bin/rails c
shogun(dev)> uuid = SecureRandom.uuid
=> "edabce2c-d8f8-436f-81e8-7cdf965df4f7"
shogun(dev)> Hoge.create(uuid: uuid, name: 'fuga')
=> #<Hoge:0x0000ffff7d6162a0 id: 8, uuid: "edabce2c-d8f8-436f-81e8-7cdf965df4f7", name: "fuga", created_at: "2025-08-01 13:51:21.850880000 +0900", updated_at: "2025-08-01 13:51:21.850880000 +0900">
shogun(dev)* time = Benchmark.realtime do
shogun(dev)*   Hoge.find_or_create_by_uuid(uuid, 'piyo')
shogun(dev)> end
shogun(dev)> puts "実行時間: #{time * 1000}ms"
実行時間: 7.614292000653222ms

実行時間は7.6msでした。

create_or_find_byの場合

b23bf9d416ff:/app# ./bin/rails c -s
shogun(dev)> uuid = SecureRandom.uuid
=> "bfcfb985-8759-4ccb-ac7e-f9d91458d626"
shogun(dev)> Hoge.create(uuid: uuid, name: 'fuga')
=> #<Hoge:0x0000ffff8de03598 id: 9, uuid: "bfcfb985-8759-4ccb-ac7e-f9d91458d626", name: "fuga", created_at: "2025-08-01 13:52:56.587854000 +0900", updated_at: "2025-08-01 13:52:56.587854000 +0900">
shogun(dev)* time = Benchmark.realtime do
shogun(dev)*   Hoge.create_or_find_by_uuid(uuid, 'piyo')
shogun(dev)> end
shogun(dev)> puts "実行時間: #{time * 1000}ms"
実行時間: 12.433457995939534ms

実行時間は12.4msでした。
やはりcreate_or_find_byの方が若干遅いっぽいですね。とはいえ手元のスペックでは5ms程度の差でした。

INSERT->SELECTのコンフリクトの可能性

こちらもコードコメントにもあります。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation.rb#L195-L198

リクエスト1とリクエスト2が同時に来た場合を想定すると以下のような状態でコンフリクトが起きそうです。

とはいえ図にある通り以下を満たさないと再現はしなさそうです。

  • 同時にリクエストが来る
  • create_or_find_byの直後にdestroyをする
  • destoryの後に後続リクエストでfind_byが走る

modelのvalidationをかけている場合は上手く動かないことがある

以下のようにmodelにもUNIQUE制約のvalidationをかけているとします。

hoge.rb
class Hoge < ApplicationRecord
  validates :uuid, presence: true, uniqueness: true
  validates :name, presence: true
end

するとcreate_or_find_by!を使うと以下のようになります。

b23bf9d416ff:/app# ./bin/rails c
shogun(dev)> uuid = SecureRandom.uuid
=> "69c346c6-2c56-49fe-a61b-b65fbeb9f330"
shogun(dev)> Hoge.create(uuid: uuid, name: 'fuga')
=> #<Hoge:0x0000ffff5bbe6fd0 id: 12, uuid: "69c346c6-2c56-49fe-a61b-b65fbeb9f330", name: "fuga", created_at: "2025-08-01 16:38:52.840131000 +0900", updated_at: "2025-08-01 16:38:52.840131000 +0900">
shogun(dev)* hoge1 = Hoge.create_or_find_by!(uuid: uuid) do |h|
shogun(dev)*   h.name = 'fuga'
shogun(dev)> end
(shogun):3:in '<main>': Validation failed: Uuid has already been taken (ActiveRecord::RecordInvalid)

modelのvalidationで落ちるのでActiveRecord::RecordInvalidがraiseされるためですね...
ちなみにcreate_or_find_byであれば問題なかったです。

b23bf9d416ff:/app# ./bin/rails c
shogun(dev)> uuid = SecureRandom.uuid
=> "6c0d48aa-50e1-455d-bbc9-f20064219753"
shogun(dev)> Hoge.create(uuid: uuid, name: 'fuga')
=> #<Hoge:0x0000ffff70d52120 id: 11, uuid: "6c0d48aa-50e1-455d-bbc9-f20064219753", name: "fuga", created_at: "2025-08-01 16:37:05.796180000 +0900", updated_at: "2025-08-01 16:37:05.796180000 +0900">
shogun(dev)* hoge1 = Hoge.create_or_find_by(uuid: uuid) do |h|
shogun(dev)*   h.name = 'fuga'
shogun(dev)> end
=> #<Hoge:0x0000ffff70888360 id: nil, uuid: "6c0d48aa-50e1-455d-bbc9-f20064219753", name: "fuga", created_at: nil, updated_at: nil>
shogun(dev)> Hoge.all
  TRANSACTION (0.3ms)  SAVEPOINT active_record_5
  Hoge Load (0.9ms)  SELECT `hoges`.* FROM `hoges` /* loading for pp */ LIMIT 11
=> 
[#<Hoge:0x0000ffff72432598
  id: 11,
  uuid: "6c0d48aa-50e1-455d-bbc9-f20064219753",
  name: "fuga",
  created_at: "2025-08-01 16:37:05.796180000 +0900",
  updated_at: "2025-08-01 16:37:05.796180000 +0900">]

これは内部的に呼ばれているmethodがcreateだからな気がしますね。
とはいえcreate_or_find_by!が使いたいケースもあると思います、その際はstrictが使えます。

class Hoge < ApplicationRecord
  validates :uuid, presence: true, uniqueness: true, strict: ActiveRecord::RecordNotUnique
  validates :name, presence: true
end
b23bf9d416ff:/app# ./bin/rails c
shogun(dev)> uuid = SecureRandom.uuid
=> "e82a96df-e525-4c99-888b-eeccf9e20287"
shogun(dev)> Hoge.create(uuid: uuid, name: 'fuga')
=> #<Hoge:0x0000ffff83f4e4c0 id: 13, uuid: "e82a96df-e525-4c99-888b-eeccf9e20287", name: "fuga", created_at: "2025-08-01 16:44:32.665228000 +0900", updated_at: "2025-08-01 16:44:32.665228000 +0900">
shogun(dev)* hoge1 = Hoge.create_or_find_by!(uuid: uuid) do |h|
shogun(dev)*   h.name = 'fuga'
shogun(dev)> end
=> #<Hoge:0x0000ffff8287a640 id: 13, uuid: "e82a96df-e525-4c99-888b-eeccf9e20287", name: "fuga", created_at: "2025-08-01 16:44:32.665228000 +0900", updated_at: "2025-08-01 16:44:32.665228000 +0900">

とはいえstrictでRailsのデフォルトの挙動を変えてしまう部分もあるため、利用するときはよく影響を考える必要がありそうですね。

まとめ

本記事はcreate_or_find_byという関数を使う際の注意点についての記事でした。
注意点としては以下です。

  • DBレベルでUNIQUE制約をつけておく
  • ActiveRecord::RecordNotFoundのハンドリングを考慮する
  • rescueによるハンドリングをしているため若干重い
  • INSERT->SELECTのコンフリクトの可能性
  • modelのvalidationをかけている場合は上手く動かないことがある

色々と注意点はありつつも既存のfind_or_create_byで起きるSELECT->INSERTのコンフリクトを回避できるのはでかいですね。
用法用量を守っていいRailsライフを過ごしてください!!

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation.rb#L209-L213

SMARTCAMP Engineer Blog

Discussion