#4513·ent

Generated ent/tx.go can lose the stdsql "database/sql" import (undefined: stdsql)

Author: yuki2006Created Aug 22, 2026Updated Aug 22, 2026
  • The issue is present in the latest release.
  • I have searched the issues of this repository and believe that this is not a duplicate.

Current Behavior

With the sql/execquery feature enabled, code generation sometimes produces an ent/tx.go that contains the txDriver.ExecContext / QueryContext methods but not the stdsql "database/sql" import, so the generated package does not compile:

entc/load: # example.com/myapp/ent
../ent/tx.go:365:82: undefined: stdsql
../ent/tx.go:367:49: undefined: stdsql
../ent/tx.go:377:84: undefined: stdsql
../ent/tx.go:379:51: undefined: stdsql

Regenerating produces a correct file, so in CI it looks like a flake. It is a race in assets.format(): tx.tmpl never emits that import — goimports does, and goimports runs concurrently with the writes of the files it needs to read.

1. tx.tmpl does not include additional imports.

The sql/execquery feature is split across two blocks in entc/gen/template/dialect/sql/feature/execquery.tmpl:

  • import/additional/stdsql (line 9) — the stdsql "database/sql" import
  • tx/additional/sql/execquery (line 45) — the txDriver.ExecContext / QueryContext methods

client.tmpl pulls in additional imports inside its import block:

gotemplate
{{- template "import/additional" $ }}   {{/* client.tmpl:69 */}}

tx.tmpl does not. Its import block (line 13) is a fixed list (context, sync, entgo.io/ent/dialect), and its only matchTemplate call is for the body (tx.tmpl:176, "tx/additional/*"). So the generated tx.go gets the methods but no import.

2. The missing import is supplied by goimports afterwards.

Graph.Gen() writes every asset and then re-processes all of them with imports.Process (entc/gen/graph.go:1161). For tx.go, goimports resolves the unknown identifier stdsql by learning the alias from a sibling file in the same package — client.go, which does get the import from its template.

3. That goimports pass is concurrent and rewrites the very files it reads.

go
// entc/gen/graph.go:1161
func (a assets) format() error {
	var wg errgroup.Group
	wg.SetLimit(runtime.GOMAXPROCS(0))
	for path, content := range a.files {
		path, content := path, content
		wg.Go(func() error {
			src, err := imports.Process(path, content, nil) // reads sibling files in the same dir
			if err != nil {
				return fmt.Errorf("format file %s: %w", path, err)
			}
			if err := os.WriteFile(path, src, 0644); err != nil { // truncates the sibling
				return err
			}
			return nil
		})
	}
	return wg.Wait()
}

os.WriteFile truncates before writing, so there is a window in which client.go is empty or partially written on disk. If tx.go is processed in that window, goimports cannot learn the stdsql alias — and it returns no error. It writes the file back without the import.

Why only stdsql? fmt is missing from tx.tmpl as well and is also added by goimports, but it resolves from the standard library by package name and does not depend on sibling files. stdsql is an alias, so it can only be learned from another file in the same package.

Why it is nondeterministic. For the import to be lost, tx.go's resolution has to read the directory exactly while client.go is truncated. Whether those two overlap is decided by one thing: a.files is a map[string][]byte (graph.go:1126), and Go randomizes map iteration order, so which file goes into which worker slot changes on every run. If client.go is written well before tx.go starts, or starts well after tx.go has read the directory, nothing happens.

What scales the odds:

Factor Where Effect
Iteration order for path, content := range a.files whether the two overlap at all
Worker count wg.SetLimit(runtime.GOMAXPROCS(0)) how many files are in flight together
Length of the window os.WriteFile(path, src, 0644) proportional to file size and filesystem speed
The read always happens imports.Process(path, content, nil) it only scans the directory when there is an unresolved identifier — tx.go always has one (stdsql)

client.go is the largest generated file, so it has both the longest write window and the longest processing time, which is why it is the one that tends to be in flight when tx.go is processed.

In our project (client.go ≈ 425 KB) a single os.WriteFile takes ~0.14 ms on a local APFS disk, while the whole generation takes a few seconds — hence roughly one failure in a few hundred CI runs, on a container filesystem where that write is slower.

Expected Behavior

Code generation should always produce a compilable package. Either the template emits the import it needs, or the formatting pass must not be able to lose it.

Steps to Reproduce

Reproduction repository: https://github.com/yuki2006/ent-tx-stdsql-repro (ent v0.14.6, sql/execquery, one entity, generated code checked in)

bash
git clone https://github.com/yuki2006/ent-tx-stdsql-repro
cd ent-tx-stdsql-repro
go test -v ./...
  1. TestImportIsRecoveredFromSibling — with ent/client.go intact, imports.Process restores the import into a tx.go that is missing it. This is what normally happens during generation.

  2. TestImportIsSilentlyLostWhenSiblingIsEmpty — with ent/client.go truncated to zero bytes (what a concurrent os.WriteFile does for an instant), imports.Process returns a nil error and leaves the import out. The result is exactly the broken tx.go above.

  3. TestRaceWithConcurrentSiblingRewrite — reproduces assets.format()'s access pattern: one goroutine rewrites ent/client.go with os.WriteFile in a loop while tx.go is processed 100 times. On an M-series Mac it lost the import in 65 of 100 runs:

    --- PASS: TestRaceWithConcurrentSiblingRewrite (34.62s)
        repro_test.go:105: lost the import in 65/100 runs

The end-to-end flake during real generation is much rarer, for the reasons above: the window is one os.WriteFile long, and the two files have to be in flight together. In the project where we found it (~50 entities, client.go ≈ 425 KB, Linux container CI) it hit roughly once in a few hundred runs. We could not trigger it on demand locally — 323 clean regenerations on a 12-core macOS machine (8 on the real project, 15 on a 40-entity variant, 300 on the repro above) all succeeded. Test 3 forces the window open instead, which is why it fails 65% of the time.

Your Environment

Tech Version
Go 1.25.5 (repro) / 1.27.0 (original)
Ent 0.14.6
Database MySQL
Driver https://github.com/go-sql-driver/mysql

Features enabled in the original project: sql/execquery, schema/snapshot, sql/upsert, privacy, intercept, sql/lock, sql/modifier. The repro needs only sql/execquery.

Suggested fix

Have tx.tmpl emit its own imports instead of relying on goimports to recover them, the same way client.tmpl does:

gotemplate
import (
	"context"
	"sync"

	"entgo.io/ent/dialect"
	{{- template "import/additional" $ }}
)

That removes the dependency on sibling files for this case.

Independently, assets.format() could be made robust: writing to a temporary file and renaming it (os.Rename is atomic) would remove the window in which a file is observable as empty. It may also be worth treating an unresolved identifier as an error rather than silently emitting a file that does not compile.

Possibly related: #4470 reports a different symptom (very slow generation caused by unused imports that goimports has to strip) with the same underlying cause — templates leaning on assets.format() to fix up imports instead of emitting exactly what each file needs.

Happy to send a PR for the template change if that direction sounds right.