結論
- 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 タグ |
|---|---|---|---|
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 の項目は、非ゼロだと書かれない

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 は丸ごと置き換わる

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 は消さない

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 の omitempty | encoding/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 に無いキーを読み飛ばす

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 を読むと、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 の項目へ読んだ結果は次のとおり。
| 文書の値 | Index | DataTo のエラー |
|---|---|---|
integer 1 | 1 | なし |
double 1.0 | 1 | なし |
double 1.5 | 0 | main.Item.index: firestore: float 1.500000 does not fit into int |
string "1" | 0 | main.Item.index: firestore: cannot set type int to string |
| 項目なし | 0 | なし |
Go の SDK は値を失わない変換だけを許す。未設定と 0 を分けたいなら *int にする (項目なしは nil)。
- PITFALL: 一覧を 1 件ずつ
DataToし、エラーで全体を返す実装は、手入力の 1 件の型違いで一覧 API ごと落ちる。手入力の手順に「number 型で入れる」と書く
通らない値
ドキュメント ID の規則違反は、読みと書きで違うエラーになる

ID の規則は、妥当な UTF-8、1,500 バイト以下、/ を含まない、. / .. だけでない、__.*__ に一致しない、の 5 つ (
Usage and limits
)。長さは文字数でなくバイト数で数える。
Go の SDK が送る前に検査するのは UTF-8 だけで、残りはサーバ (ここではエミュレータ) のエラーになる。
| ID | Get | Set |
|---|---|---|
不正な UTF-8 ("\xff") | Unknown | Unknown |
__x__ | InvalidArgument | InvalidArgument |
a/b / . / .. | InvalidArgument | InvalidArgument |
| 1,501 バイト | NotFound | InvalidArgument |
| 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 を渡すと、期限まで返ってこない

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(...)) |
不在の文書に Update | NotFound | 作るなら 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 と長さで検査する |