結論

  • Go の Firestore SDK は、struct とドキュメントの間で項目を消しても、読み飛ばしてもエラーを返さない
  • 書く側: Set は文書を丸ごと置き換える。serverTimestamp の項目は非ゼロだと書かれないので、読んで詰め直した Set で作成日時が消える。MergeAll は map 専用で、map の中の struct は丸ごと置き換わる。omitempty は false / 0 / ゼロ時刻を消し、struct は消さない
  • 読む側: DataTo は struct に無いキーを捨てる。null を string や int に読んでも何もしない。int には整数で表せる double は入り、文字列は入らない
  • 通らない値: ID・日時・クエリの値の規則を、SDK は一部しか検査しない。ID の規則違反は読みと書きで違うエラーになり、不正な UTF-8 を where に渡すと期限まで返ってこない
  • 書き込みの経路ごとに、エミュレータで実際の文書を読んで確かめる

以下は cloud.google.com/go/firestore v1.26.0 / Firestore エミュレータ v1.22.0 / Go 1.26.4 で実測した。 本番の Firestore では試していない。

前提: 書き込み API ごとに書く範囲が違う

書き込み API は、文書が無いときの扱いと、書く範囲と、serverTimestamp タグが効くかで分かれる

API文書が無い文書があるserverTimestamp タグ
Create(struct)作るAlreadyExists効く
Set(struct)作る丸ごと置き換える効く
Set(struct, Merge(paths...))作る指定したパスだけ書く効かない (ゼロ時刻を書く)
Set(map, MergeAll)作るmap の葉だけ書くタグが無い (値に ServerTimestamp)
Update([]Update)NotFound指定したパスだけ書くタグが無い (値に ServerTimestamp)

Set(struct) は struct に無い項目を消す。部分更新のつもりで使うと、struct に載せていない項目が文書から無くなる。

type AOnly struct {
	A int `firestore:"a"`
}

_, _ = ref.Set(ctx, map[string]any{"a": 1, "b": 2})
_, _ = ref.Set(ctx, AOnly{A: 3})
// 文書: {a: 3}  (b が消える)

_, err := ref.Set(ctx, AOnly{A: 4}, firestore.MergeAll)
fmt.Println(err) // firestore: MergeAll can only be specified with map data

_, err = col.Doc("missing").Update(ctx, []firestore.Update{{Path: "a", Value: 1}})
fmt.Println(status.Code(err)) // NotFound
  • PITFALL: Update を upsert と思って不在の文書に使うと NotFound で落ちる。MergeAll を struct に使うと実行時に落ちる。どちらもコンパイルは通る

書く側

serverTimestamp の項目は、非ゼロだと書かれない

読んで詰め直した Set では、作成日時が書き込みから外れて文書から消える

firestore:"createdAt,serverTimestamp" の項目は、ゼロ値ならサーバ時刻が入り、非ゼロなら書き込みから外れる。指定した値でも上書きされない。 godoc にも “if the field value is non-zero it won’t be saved” とある (DocumentRef.Create)。 Set(struct) は文書を丸ごと置き換えるので、外れた項目は文書から消える。

type E struct {
	Name      string    `firestore:"name"`
	CreatedAt time.Time `firestore:"createdAt,serverTimestamp"`
}

_, _ = ref.Create(ctx, E{Name: "first"}) // createdAt にサーバ時刻が入る

snap, _ := ref.Get(ctx)
var e E
_ = snap.DataTo(&e) // e.CreatedAt は非ゼロ
e.Name = "second"
_, _ = ref.Set(ctx, e)
// 文書: {name: "second"}  (createdAt が消える)

snap, _ = ref.Get(ctx)
var e2 E
_ = snap.DataTo(&e2)
fmt.Println(e2.CreatedAt.IsZero()) // true

SDK はこの項目を、値の組み立てでは常に読み飛ばす (to_value.go の continue)。 サーバ時刻を入れる指示 (transform) は、値がゼロのときだけ足す (document.go の extractTransforms)。

タグが効くのは struct を丸ごと渡す Create と Set だけで、パスを指定する経路では効かない。 Set(struct, Merge(...)) にこの項目のパスを含めると、ゼロ値の 0001-01-01T00:00:00Z がそのまま書かれる。 Update にゼロ値の time.Time を渡しても同じ。

_, _ = ref.Set(ctx, E{Name: "b"}, firestore.Merge([]string{"name"}, []string{"createdAt"}))
// 文書: {name: "b", createdAt: 0001-01-01T00:00:00Z}

_, _ = ref.Update(ctx, []firestore.Update{{Path: "createdAt", Value: firestore.ServerTimestamp}})
// 文書: {name: "b", createdAt: <サーバ時刻>}

作成日時を残すなら、作成は Create(struct) で書き、更新は Update か Set(struct, Merge(...)) で作成日時のパスを書かない。書かなければ前の値が残る。 updatedAt のように毎回打ち直す項目は、Update の値に firestore.ServerTimestamp を渡す。

  • PITFALL: updatedAt は「毎回ゼロ値から書く」使い方で正しく動くので、同じタグの createdAt も守られると思い込む。守られるのは初回 (ゼロ値) だけ。テスト用の Fake 実装がこの挙動を再現していないと、テストは通る

MergeAll は map 専用で、map の中の struct は丸ごと置き換わる

MergeAll は map の中へは潜って葉ごとに書くが、struct は 1 つの値として丸ごと書く

Set(data, MergeAll) は data の葉を全部パスとして集め、そのパスだけを書く。 葉を探して潜るのは reflect.Map と reflect.Interface だけで、struct はそこで 1 つの葉になる (docref.go の fpvsFromData)。

type Inner struct {
	A string `firestore:"a"`
	B string `firestore:"b"`
}

_, _ = ref.Set(ctx, map[string]any{
	"top":    "keep",
	"nmap":   map[string]any{"a": "1", "b": "2"},
	"nstruc": Inner{A: "1", B: "2"},
})
_, _ = ref.Set(ctx, map[string]any{
	"nmap": map[string]any{"a": "X"},
	"nstruc": struct {
		A string `firestore:"a"`
	}{A: "X"},
}, firestore.MergeAll)
// 文書: {top: "keep", nmap: {a: "X", b: "2"}, nstruc: {a: "X"}}

nmap.b は残り、nstruc.b は消える。「マージだから既存の項目は残る」が成り立つのは、map でたどれる階層だけ。

map のキーはそのまま項目名になる。struct タグは見ない。omitempty も無いので、ゼロ値もそのまま書く。

type Tagged struct {
	StartTS int64 `firestore:"startTS"`
}

_, _ = ref.Set(ctx, Tagged{StartTS: 100})
_, _ = ref.Set(ctx, map[string]any{"StartTS": int64(0)}, firestore.MergeAll)
// 文書: {startTS: 100, StartTS: 0}  (別の項目が増える)

var tags []string
_, _ = ref.Set(ctx, map[string]any{"tags": tags}, firestore.MergeAll)
// 文書に tags: null が入る

MergeAll は data に無い項目を消さない。構造を変えた後の旧項目は、文書に残り続ける。

  • PITFALL: 2 つの経路が同じ入れ子の struct を別々に書くと、後の書き込みが前の内容を丸ごと消す。「A の書いた項目を B は触らないから共存できる」は、入れ子が struct なら成り立たない

omitempty は false / 0 / ゼロ時刻を消し、struct は消さない

omitempty で消えるのはスカラーのゼロ値と空のコレクションとゼロ時刻で、ゼロ値の struct は全サブキーが残る

Firestore の omitempty の空判定 (isEmptyValue) は、encoding/json の判定に time.Time のゼロ値を足したもの。 struct はゼロ値でも空と見なさず、全部のサブキーを書く。

type Range struct {
	Min int `firestore:"min"`
	Max int `firestore:"max"`
}

type O struct {
	Flag  bool      `firestore:"flag,omitempty"`
	N     int       `firestore:"n,omitempty"`
	Empty []string  `firestore:"empty,omitempty"`
	T     time.Time `firestore:"t,omitempty"`
	P     *int      `firestore:"p,omitempty"`
	R     Range     `firestore:"r,omitempty"`
	RZ    Range     `firestore:"rz,omitzero"`
	RP    *Range    `firestore:"rp,omitempty"`
	Nil   []string  `firestore:"nil"`
	NoOmT time.Time `firestore:"noOmT"`
}

_, _ = ref.Set(ctx, O{Empty: []string{}})
// 文書: {r: {min: 0, max: 0}, nil: null, noOmT: 0001-01-01T00:00:00Z}
ゼロ値Firestore の omitemptyencoding/json の omitempty
false / 0 / ""消える消える
nil か長さ 0 のスライス・map消える消える
nil ポインタ消える消える
time.Time{}消える"0001-01-01T00:00:00Z" が出る
struct全サブキーが出る全サブキーが出る

ゼロ値の struct を消すなら omitzero (v1.20.0 で追加) を使うか、ポインタにして nil で表す。 タグを付けない nil スライスは null、ゼロ時刻は 0001-01-01T00:00:00Z で書かれる。

サブ項目が全部 omitempty で消える struct は、キーが消えずに空の map として残る。

type RangeOmit struct {
	Min time.Time `firestore:"minTS,omitempty"`
	Max time.Time `firestore:"maxTS,omitempty"`
}

type O2 struct {
	R RangeOmit `firestore:"r,omitempty"`
}

_, _ = ref.Set(ctx, O2{})
// 文書: {r: {}}

bool の false が消える話は Go言語omitemptyの罠:falseが保存できない にまとめた。

  • PITFALL: 同じ項目でも、書き込みの経路で表現が分かれる。作成は struct 経由で omitempty が効いてキーが無く、更新は map 経由で 0 が入り、MergeAll に nil スライスを渡すと null が入る。キーの有無で分岐するクライアントには、経路ごとに違う文書が届く

読む側

DataTo は struct に無いキーを読み飛ばす

文書のキーと struct の項目を照合し、struct に無いキーは捨て、文書に無い項目はゼロ値のまま残る

DocumentSnapshot.DataTo は文書の各キーを struct の項目に照合し、無ければ読み飛ばす (from_value.go の populateStruct)。 struct にあって文書に無い項目はゼロ値のまま残る。どちらもエラーにならない。

_, _ = ref.Set(ctx, map[string]any{"categoryID": "c1", "legacy": "x"})

type Renamed struct {
	CategoryIDs []string `firestore:"categoryIDs"` // categoryID から改名した
}

snap, _ := ref.Get(ctx)
var v Renamed
fmt.Println(snap.DataTo(&v), v.CategoryIDs == nil) // <nil> true

struct から項目を消すのは安全で、改名は危険。改名後の struct で読むと、移していない文書は全件ゼロ値になる。 改名は「新旧両方を読める期間を作る => データを移す => 旧項目を消す」の 3 段階にする。 移し終えたかは、旧項目を持つ文書が 1 件でも引けるかで見る。

docs, _ := col.Where("categoryID", "!=", "").Limit(1).Documents(ctx).GetAll()
fmt.Println(len(docs)) // 1 なら、まだ旧項目が残っている
  • PITFALL: 改名をデプロイすると、カテゴリ検索が常に 0 件になるような障害が、ログにもメトリクスにも残らずに起きる。逆に struct から消した項目は文書に残り続け、Set で丸ごと置き換えるまで消えない

null は string や int に何もしない

null を読むと、nil を持てる型だけがゼロ値になり、string と int は前の値のまま残る

null を読むと、SDK は nil を持てる型 (interface / ポインタ / map / スライス) だけをゼロ値にし、それ以外には何もしない。エラーも返さない (from_value.go)。

// A Null value sets anything nullable to nil, and has no effect
// on anything else.
if _, ok := valTypeSrc.(*pb.Value_NullValue); ok {
	switch vDest.Kind() {
	case reflect.Interface, reflect.Ptr, reflect.Map, reflect.Slice:
		vDest.Set(reflect.Zero(vDest.Type()))
	}
	return nil
}

既に値の入った struct へ読むと、string と int は前の値のまま残る。

_, _ = ref.Set(ctx, map[string]any{"s": nil, "n": nil, "p": nil, "sl": nil})

type NullT struct {
	S  string   `firestore:"s"`
	N  int      `firestore:"n"`
	P  *string  `firestore:"p"`
	SL []string `firestore:"sl"`
}

pre := "pre"
v := NullT{S: "pre", N: 7, P: &pre, SL: []string{"pre"}}
snap, _ := ref.Get(ctx)
fmt.Println(snap.DataTo(&v))                  // <nil>
fmt.Println(v.S, v.N, v.P == nil, v.SL == nil) // pre 7 true true

書く側も止めない。string の項目へ Update で nil を渡すと null が書かれ、ゼロ値の struct へ読み戻すと "" になる。

_, err := ref.Update(ctx, []firestore.Update{{Path: "name", Value: nil}})
fmt.Println(err) // <nil>  (文書: {name: null})

「この項目は nil を持てるか」の検査は、Firestore の手前のドメイン層に置く。

  • PITFALL: サーバもテストも通ったまま、本番の文書だけが null になる。テストダブルの方が型を厳しく見ていると、本番との違いに気づけない

int の項目には、整数で表せる double は入り、文字列は入らない

Firestore の数値は integer と double の 2 型があり、コンソールから手で入れるとどちらにもなる。 DataTo で Index int の項目へ読んだ結果は次のとおり。

文書の値IndexDataTo のエラー
integer 11なし
double 1.01なし
double 1.50main.Item.index: firestore: float 1.500000 does not fit into int
string "1"0main.Item.index: firestore: cannot set type int to string
項目なし0なし

Go の SDK は値を失わない変換だけを許す。未設定と 0 を分けたいなら *int にする (項目なしは nil)。

  • PITFALL: 一覧を 1 件ずつ DataTo し、エラーで全体を返す実装は、手入力の 1 件の型違いで一覧 API ごと落ちる。手入力の手順に「number 型で入れる」と書く

通らない値

ドキュメント ID の規則違反は、読みと書きで違うエラーになる

ID の規則違反は、SDK が弾くもの、サーバが弾くもの、読みでは NotFound になるものに分かれる

ID の規則は、妥当な UTF-8、1,500 バイト以下、/ を含まない、. / .. だけでない、__.*__ に一致しない、の 5 つ ( Usage and limits )。長さは文字数でなくバイト数で数える。 Go の SDK が送る前に検査するのは UTF-8 だけで、残りはサーバ (ここではエミュレータ) のエラーになる。

IDGetSet
不正な UTF-8 ("\xff")UnknownUnknown
__x__InvalidArgumentInvalidArgument
a/b / . / ..InvalidArgumentInvalidArgument
1,501 バイトNotFoundInvalidArgument
1,500 バイトNotFound (無いだけ)成功
p___x__NotFound (無いだけ)成功

Unknown は SDK が返す firestore: ID in DocumentRef contains invalid UTF-8 characters で、gRPC のステータスを持たない。 __ で始まらない p___x__ は __.*__ に一致しないので通る。

利用者が渡した値を ID に使うなら、SDK に渡す前にこの規則で検査し、読みは 404、書きは 400 に揃える。

ID で引く取得には、where のような条件を付けられない。テナントで分けたいなら、テナント ID を ID の頭に付ける (<tenantId>_<termsId>)。 UUID は - を含むので、区切りには UUID が含まない _ を使う。/ 区切りのパス風の ID は作れない。

  • PITFALL: 検査しないと、種類のばらばらなエラーがまとめて 500 として表に出る

日時は 0001 年から 9999 年まで、保存はマイクロ秒まで

日時の範囲外はサーバが拒否し、マイクロ秒より細かい値で引くとエミュレータでは当たらない

Firestore の日時は google.protobuf.Timestamp で運ばれ、0001-01-01T00:00:00Z から 9999-12-31T23:59:59.999999999Z までしか持てない ( google.protobuf.Timestamp )。 Go の time.Time は 0 年も 10000 年も表せるので、範囲外はサーバで初めて弾かれる。

type TS struct {
	At time.Time `firestore:"at"`
}

_, err := ref.Set(ctx, TS{At: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC)})
fmt.Println(err)
// rpc error: code = InvalidArgument desc = Timestamp seconds exceeds limit for field.

time.Time{} は 0001-01-01T00:00:00Z で、範囲の下端ちょうどなので書ける。9999-12-31T23:59:59.999999999Z も書けた。 利用者が送った日時をそのまま保存するなら、パースした後、書く前に範囲を検査して 400 にする。 API の仕様では通る値が、DB の層で 500 になる。

保存の精度はマイクロ秒で、それより細かい部分は切り捨てられる ( Supported data types )。 エミュレータでは、クエリの値は切り捨てられなかった。ナノ秒を持つ値で書いて同じ値で引くと、0 件になる。

ns := time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC)
_, _ = col.Doc("t").Set(ctx, TS{At: ns}) // 読み戻すと 03:04:05.123456

eq, _ := col.Where("at", "==", ns).Documents(ctx).GetAll()
fmt.Println(len(eq)) // 0

us := ns.Truncate(time.Microsecond)
eq, _ = col.Where("at", "==", us).Documents(ctx).GetAll()
fmt.Println(len(eq)) // 1

>= と <= で挟んでも同じで、ナノ秒の値では 0 件、マイクロ秒に切り捨てた値では 1 件になる。REST API でナノ秒の timestampValue を投げても 0 件だった。 本番でクエリの値も切り捨てられるかは確かめていない。 どちらでも同じ結果にするなら、書く前とクエリの前に Truncate(time.Microsecond) を通す。

  • PITFALL: Linux の time.Now() はナノ秒の端数を持つ。time.Now() で書いた値を、手元に残した同じ値で == で引くと、エミュレータでは 0 件になった

where に不正な UTF-8 を渡すと、期限まで返ってこない

不正な UTF-8 は送信前の変換で Internal になり、SDK がそれを再試行し続けて期限切れで返る

where に渡す文字列は、1,500 バイト以上なら InvalidArgument (value for serviceId is too large to be used in a query) ですぐ返る。1,499 バイトは通る。 不正な UTF-8 は返ってこない。3 秒の期限を付けた context で、3 秒後に context deadline exceeded になった。

qctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
_, err := col.Where("serviceId", "==", "\xff").Documents(qctx).GetAll()
fmt.Println(err, errors.Is(err, context.DeadlineExceeded)) // context deadline exceeded true  (3 秒後)

原因は送信前の変換にある。gRPC がリクエストを変換する段階で Internal (grpc: error while marshaling: string field contains invalid UTF-8) になり、サーバまで届かない。 SDK の RunQuery は既定で Unavailable / Internal / DeadlineExceeded を再試行する (apiv1/firestore_client.go) ので、毎回同じ理由で失敗する呼び出しを繰り返す。回数の上限は無く、止まるのは context の期限だけ。 ID の不正な UTF-8 は SDK が先に検査してすぐ返すが、where の値は検査しない。

if !utf8.ValidString(v) || len(v) >= 1500 {
	return nil, nil // 一致する文書は無い
}
  • PITFALL: 症状は 500 ではなく応答の停止。テストも context.WithTimeout で縛らないと、失敗がテスト全体のタイムアウトとして出る

まとめ

場面挙動対策
Set(struct)文書を丸ごと置き換え、struct に無い項目を消す部分更新は Update か Set(..., Merge(...))
不在の文書に UpdateNotFound作るなら Create / Set
serverTimestamp + 非ゼロ値書かれない。Set なら項目ごと消える更新では作成日時のパスを書かない
serverTimestamp + パス指定ゼロ時刻 0001-01-01 を書く値に firestore.ServerTimestamp を渡す
MergeAll + struct実行時エラーmap で渡す
MergeAll + map の中の struct丸ごと置き換える入れ子も map で渡す
map のキータグを見ずにそのまま項目名になるキーをタグと揃える
omitempty + ゼロ値の struct全サブキーを書くomitzero かポインタ
omitempty + time.Time{}消える (JSON では出る)経路ごとに文書を確かめる
struct に無いキーを DataTo読み飛ばす改名は 3 段階で移す
null を string / int へ何もしないドメイン層で nil を弾く
int へ文字列 / 小数エラー。整数で表せる double は入る手入力は number 型で入れる
ID の規則違反読みと書きでエラーが違う渡す前に検査し、404 / 400 に揃える
範囲外の日時InvalidArgument書く前に範囲を検査し 400
ナノ秒の日時保存はマイクロ秒に切り捨て。エミュレータではナノ秒の値で引くと当たらない書く前とクエリの前に Truncate(time.Microsecond)
where に不正な UTF-8期限まで再試行するutf8.ValidString と長さで検査する

参考