How to Embed SQL Migration Files in Go

Embed SQL migration files in Go using the embed package to bundle them directly into the binary for easy deployment.

The single-binary promise

You deploy a Go service to production. The binary starts, connects to the database, and immediately panics. The error points to a missing SQL file. The migration scripts live in a migrations directory, but the deployment pipeline only copied the executable. The assets are stranded on the developer's machine. This scenario happens often when applications depend on external data files. Go offers a solution that eliminates the risk of missing assets: compile-time embedding. By baking files directly into the binary, you guarantee that the code and its data always travel together.

How embedding works

The embed package turns external files into Go code during compilation. When you mark a variable with a special comment, the compiler scans the filesystem, reads the matching files, and embeds their contents as constants inside your binary. The result is a single executable that carries its own data. No external dependencies. No missing files. The files become part of the program's memory layout.

Think of embed.FS as a virtual drive inside your executable. It implements the standard io/fs.FS interface, so you can use familiar functions like ReadFile and ReadDir to access the contents. The data lives in the binary's read-only section. Accessing it is fast because there is no disk I/O. The operating system loads the binary into memory, and the embedded files are already there.

Minimal example

Here's the simplest setup: a variable tagged with a glob pattern that captures every SQL file in the current directory.

package main

import (
	"embed"
	"fmt"
	"log"
)

//go:embed *.sql
// The compiler reads all .sql files in this directory and packs them into the binary.
// If no files match the pattern, the build fails immediately.
var sqlFiles embed.FS

func main() {
	// Read a specific file from the embedded filesystem.
	// The path is relative to the directory where the comment lives.
	content, err := sqlFiles.ReadFile("001_create_users.sql")
	if err != nil {
		log.Fatal(err)
	}

	// Print the first 50 bytes to prove the file is inside the binary.
	// The content is a byte slice pointing to embedded data.
	fmt.Printf("Embedded SQL: %s\n", string(content[:50]))
}

Walkthrough

When you run go build, the compiler sees the //go:embed comment. It validates the glob pattern against the files present in the directory. If no files match, the build fails. The compiler then reads the bytes of each file and generates Go code that stores those bytes in the binary's read-only data section. At runtime, ReadFile returns a slice pointing to that embedded data. There is no disk I/O. The data is already in memory as part of the executable.

The comment must appear immediately above the variable declaration. The variable must be of type embed.FS or embed.File. The path in the comment is relative to the Go source file, not the module root. If you move the Go file, the paths break. The compiler catches this at build time, which prevents runtime surprises.

Realistic migration runner

Real applications keep migrations in a dedicated folder. This example shows how to embed a subdirectory, iterate over the files, sort them, and read the contents.

package main

import (
	"embed"
	"fmt"
	"io/fs"
	"log"
	"sort"
)

//go:embed migrations/*.sql
// Embed all SQL files inside the migrations subdirectory.
// The path prefix 'migrations/' is preserved in the embedded paths.
// You must include this prefix when reading files.
var migrationFS embed.FS

// RunMigrations reads and executes SQL files in sorted order.
// It demonstrates iterating over an embedded filesystem.
func RunMigrations() error {
	// Read the directory entries from the embedded FS.
	// This returns metadata, not the file contents yet.
	entries, err := fs.ReadDir(migrationFS, "migrations")
	if err != nil {
		return fmt.Errorf("read migrations dir: %w", err)
	}

	// Sort entries by name to ensure migrations run in sequence.
	// Embedded FS does not guarantee order, so explicit sorting is required.
	sort.Slice(entries, func(i, j int) bool {
		return entries[i].Name() < entries[j].Name()
	})

	for _, entry := range entries {
		// Skip directories if the glob pattern was loose.
		if entry.IsDir() {
			continue
		}

		// Construct the full path including the subdirectory prefix.
		// The embedded path must match exactly what the compiler stored.
		path := "migrations/" + entry.Name()
		content, err := migrationFS.ReadFile(path)
		if err != nil {
			return fmt.Errorf("read %s: %w", path, err)
		}

		// In a real app, you would pass content to a database driver here.
		// For now, log the migration name and size.
		fmt.Printf("Applying migration: %s (%d bytes)\n", entry.Name(), len(content))
	}

	return nil
}

Designing for testability

The embed.FS type implements the io/fs.FS interface. This is intentional. You can write functions that accept fs.FS and pass either an embedded filesystem or a directory on disk. This pattern makes testing trivial. In production, you pass the embedded FS. In tests, you pass os.DirFS("testdata"). The logic remains identical.

package main

import (
	"io/fs"
	"sort"
)

// ProcessMigrations accepts any filesystem implementation.
// This allows testing with os.DirFS while production uses embed.FS.
// The function signature depends on the interface, not the concrete type.
func ProcessMigrations(system fs.FS, dir string) error {
	entries, err := fs.ReadDir(system, dir)
	if err != nil {
		return err
	}

	// Sort to ensure deterministic processing order.
	sort.Slice(entries, func(i, j int) bool {
		return entries[i].Name() < entries[j].Name()
	})

	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}

		// Read file using the passed filesystem.
		// The path construction depends on the directory structure.
		path := dir + "/" + entry.Name()
		content, err := fs.ReadFile(system, path)
		if err != nil {
			return err
		}

		// Process the content.
		_ = content
	}

	return nil
}

This design separates the data source from the logic. You can unit test ProcessMigrations without compiling the binary. You can also swap the embedded filesystem for a real directory during development if you want to edit SQL files without rebuilding. The convention is to accept fs.FS in function parameters and return concrete types like []byte or string. This keeps your API flexible.

Pitfalls and compiler errors

The compiler is strict about embedded files. If your glob pattern matches nothing, the build stops with embed: migrations/*.sql: no matching files found. This happens if you run the build from a different directory or if the files are ignored by .gitignore and you're building from a fresh clone without the assets. The path in the comment is relative to the Go source file, not the module root. If you move the Go file, the paths break. You get open migrations/001.sql: file does not exist at runtime if the path string is wrong, or a compile error if the file is missing during build.

Embedding large files increases binary size and compile time. The compiler has to read and encode every byte. If you embed a 50MB SQL dump, your binary grows by 50MB. The build takes longer because the compiler processes the data. Keep embedded files small. Use embedding for configuration, templates, and migration scripts. Avoid embedding large datasets or media files.

Glob patterns support negation. You can exclude files by adding a negated pattern. For example, //go:embed *.sql !*.draft.sql embeds all SQL files except those ending in .draft.sql. This is useful for keeping work-in-progress files out of the binary. The compiler validates the negation at build time. If the negation removes all files, the build fails.

Decision matrix

Use embed.FS when you need a single binary distribution with no external assets. Use embed.File when you only need one specific file and want a simple byte slice instead of a filesystem interface. Use external files on disk when the data changes frequently without recompilation, such as user-uploaded templates or hot-swappable configuration. Use a database seed script instead of embedding when the data volume is large and should be managed by the database tooling rather than the application binary.

Embed files early. Fix paths before the binary ships.

The compiler checks your assets. Trust the build failure.

Single binary means single responsibility. Keep the embedded size reasonable.

Where to go next