gastownhall/beads · error

reading migration %s: %w

Error message

reading migration %s: %w

What it means

This error wraps an os/fs.ReadError that occurs when the schema package tries to read a pending migration file from its embedded filesystem (m.files.ReadFile at internal/storage/schema/schema.go:1337) while computing which dirty tables pending migrations will touch. The library throws it because a migration that list() reported as available could not actually be read from the migration source, so the dirty-table analysis cannot proceed safely. The wrapped underlying error (in the %w) names the real cause.

Source

Thrown at internal/storage/schema/schema.go:1337

func (m migrationSource) pendingMigrationDirtyTables(ctx context.Context, db DBConn, dirtyBefore map[string]dirtyTableState) ([]string, error) {
	if len(dirtyBefore) == 0 {
		return nil, nil
	}
	current, err := m.currentVersion(ctx, db)
	if err != nil {
		return nil, err
	}

	dirtyNames := sortedDirtyTableNames(dirtyBefore)
	touched := make(map[string]struct{})
	for _, mf := range m.list() {
		if mf.version <= current {
			continue
		}
		data, err := m.files.ReadFile(m.dir + "/" + mf.name)
		if err != nil {
			return nil, fmt.Errorf("reading migration %s: %w", mf.name, err)
		}
		sqlText := string(data)
		for _, table := range dirtyNames {
			if migrationSQLTouchesTable(sqlText, table) {
				touched[table] = struct{}{}
			}
		}
	}

	names := make([]string, 0, len(touched))
	for table := range touched {
		names = append(names, table)
	}
	sort.Strings(names)
	return names, nil
}

func migrationSQLTouchesTable(sqlText, table string) bool {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Rebuild the binary (go build ./...) so the embedded filesystem matches the migration files on disk
  2. Verify the //go:embed pattern covers the migration directory and every .sql file
  3. Confirm m.dir matches the embedded directory path exactly (case-sensitive)
  4. Inspect the wrapped error in the message for the exact OS cause (no such file vs permission)
  5. If the file was intentionally removed, delete or supersede its migration record instead of leaving a dangling entry

Example fix

// before (embed misses new migration dir)
//go:embed migrations/*.sql
var files embed.FS
m := migrationSource{files: files, dir: "sqlmigrations"}
// after (dir matches embed root)
//go:embed migrations/*.sql
var files embed.FS
m := migrationSource{files: files, dir: "migrations"}
Defensive patterns

Strategy: try-catch

Validate before calling

// Before calling into migration code, verify every listed migration is readable.
for _, mf := range src.list() {
    if _, err := src.files.ReadFile(src.dir + "/" + mf.name); err != nil {
        return fmt.Errorf("migration %s unreadable at startup: %w", mf.name, err)
    }
}

Type guard

func migrationReadable(src migrationSource, mf migrationFile) bool {
    f, err := src.files.ReadFile(src.dir + "/" + mf.name)
    return err == nil && len(f) > 0
}

Try / catch

names, err := m.pendingMigrationDirtyTables(ctx, db, dirtyBefore)
if err != nil {
    var pathErr *fs.PathError
    if errors.As(err, &pathErr) {
        // embedded FS mismatch: rebuild binary or fix embed directive
        log.Printf("migration file unreadable: %s", pathErr.Path)
    }
    return err
}

Prevention

When it happens

Trigger: Calling pendingMigrationDirtyTables (via the migrate path) when the embedded FS entry at m.dir/mf.name is missing or unreadable — e.g. m.dir is misregistered in the embed directive, the migration file was renamed/deleted without rebuilding, or list() and files disagree (stale binary, build cache issue).

Common situations: Adding a migration file but forgetting the //go:embed directive; deleting or renaming a migration while a stale build still lists it; case-sensitivity mismatches (works on macOS, fails on Linux CI); embedding from the wrong directory so dir prefix doesn't match.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/dacb9d742179f7e8. Report an issue: GitHub.