Understanding embed.FS
Go's embed
package, introduced in Go 1.16, fundamentally changed how we handle static assets in Go applications. At its core, embed.FS
implements the standard fs.FS
interface, creating a read-only file system that exists entirely in memory as part of your compiled binary.
When you use //go:embed
directives, the Go compiler reads files from your source tree during the build process and converts them into byte data that gets compiled directly into your executable. This happens at build time, not runtime - meaning the files you're embedding must exist when you compile your program, and they become immutable parts of your binary.
package main
import (
"embed"
"io/fs"
)
//go:embed static/*
var staticFiles embed.FS
//go:embed config.yaml
var configFile []byte
//go:embed templates/*.html
var templates embed.FS
The embed.FS
type provides a complete file system interface. You can call Open()
to get file handles, ReadDir()
to list directory contents, and ReadFile()
for direct file reading. Unlike traditional file operations that hit the disk, these operations work entirely in memory, accessing pre-loaded data structures built into your binary.
Performance characteristics differ significantly from disk-based file access. Reading embedded files eliminates filesystem latency entirely - there are no system calls, no disk seeks, and no network delays if you're running on distributed storage. However, this comes with memory overhead. Every embedded file consumes RAM for the lifetime of your process, whether you access it or not.
The embedded file system maintains the original directory structure from your source tree. If you embed static/css/main.css
, you access it using that same path through the embed.FS
interface. The file system is case-sensitive and uses forward slashes regardless of your host operating system.
One crucial aspect to understand is that embed.FS
creates a completely separate namespace from your host filesystem. When you call staticFiles.Open("style.css")
, you're not opening a file relative to your current working directory - you're opening a file from the embedded filesystem that was captured at build time from your source tree.
Embedding Strategies
The way you structure your embed directives directly impacts both your build process and runtime performance. Go provides several approaches for including files, each with distinct trade-offs you need to consider.
Single file embedding works well for configuration files, small templates, or individual assets. When you embed a single file into a []byte
or string
, Go stores it as a simple byte array in your binary:
//go:embed version.txt
var version string
//go:embed logo.png
var logoData []byte
This approach minimizes overhead for small files but becomes unwieldy when you need to manage multiple related assets. You'll end up with numerous package-level variables, making your code harder to organize.
Directory tree embedding provides a cleaner solution for related files. When you embed an entire directory, Go creates a hierarchical file system structure:
//go:embed static
var staticAssets embed.FS
//go:embed templates
var templateFiles embed.FS
The embedded file system preserves the complete directory structure, including subdirectories. If your static
folder contains css/main.css
and js/app.js
, both files remain accessible at their original paths within the embedded filesystem.
Glob patterns offer the most flexible approach, letting you selectively include files based on patterns rather than directory boundaries:
//go:embed templates/*.html templates/*.txt
var templates embed.FS
//go:embed static/css/*.css static/js/*.js
var webAssets embed.FS
//go:embed **/*.json
var configFiles embed.FS
The **
pattern recursively matches subdirectories, while single *
matches only within the current directory level. You can combine multiple patterns in a single directive, and Go will merge all matching files into one embedded filesystem.
Managing embedded asset size becomes critical in production deployments. Every embedded file increases your binary size and memory footprint. Large binaries take longer to download, use more disk space, and consume additional RAM. Consider excluding development-only files, test fixtures, or documentation from your embedded assets.
You can use build tags to conditionally embed different asset sets:
//go:build !dev
//go:embed dist/minified
var webAssets embed.FS
//go:build dev
//go:embed src/unminified
var webAssets embed.FS
For applications with extensive static assets, consider splitting them across multiple embed directives based on usage patterns. Frequently accessed files might go in one embedded filesystem, while rarely used assets go in another. This doesn't reduce total memory usage but can improve cache locality and make your code more organized.
Remember that embedded files cannot be modified at runtime. If you need to update assets without recompiling, you'll need to implement a fallback mechanism that checks the regular filesystem before falling back to embedded files.
Integration Patterns
Embedded file systems integrate seamlessly with Go's standard HTTP and template packages, but each integration pattern requires specific considerations for optimal performance and maintainability.
HTTP file serving becomes straightforward with embed.FS
through the http.FS
adapter. The standard library provides http.FileServer
that works directly with embedded filesystems:
//go:embed static
var staticFiles embed.FS
func main() {
// Serve embedded files from /static/ URL path
staticFS, _ := fs.Sub(staticFiles, "static")
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Alternative: serve from root
http.Handle("/", http.FileServer(http.FS(staticFiles)))
}
The fs.Sub
function creates a subtree view of your embedded filesystem, letting you mount specific directories at different URL paths. This approach handles HTTP headers, content types, and caching automatically, but you lose control over fine-grained response customization.
For more control, read embedded files directly and serve them through custom handlers:
func serveAsset(w http.ResponseWriter, r *http.Request) {
content, err := staticFiles.ReadFile("css/main.css")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/css")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(content)
}
Template systems work naturally with embedded files, though you need to use template.ParseFS
instead of template.ParseFiles
:
//go:embed templates/*.html
var templateFiles embed.FS
func loadTemplates() (*template.Template, error) {
return template.ParseFS(templateFiles, "templates/*.html")
}
// For html/template with embedded files
func executeTemplate(w http.ResponseWriter, name string, data interface{}) {
tmpl, err := template.ParseFS(templateFiles, "templates/"+name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl.Execute(w, data)
}
You can also pre-parse templates at startup and cache them in memory, which eliminates the parsing overhead on each request:
var templates *template.Template
func init() {
templates = template.Must(template.ParseFS(templateFiles, "templates/*.html"))
}
Configuration file embedding works well for default configurations that applications can override at runtime:
//go:embed config/defaults.yaml
var defaultConfig []byte
type Config struct {
Database DatabaseConfig `yaml:"database"`
Server ServerConfig `yaml:"server"`
}
func loadConfig(configPath string) (*Config, error) {
var config Config
// Start with embedded defaults
if err := yaml.Unmarshal(defaultConfig, &config); err != nil {
return nil, err
}
// Override with external config if provided
if configPath != "" {
external, err := os.ReadFile(configPath)
if err == nil {
yaml.Unmarshal(external, &config)
}
}
return &config, nil
}
This pattern gives you sensible defaults while maintaining flexibility for deployment-specific configurations. The embedded config serves as fallback when external configuration files are missing or incomplete.
When integrating embedded assets with middleware, remember that embed.FS
operations are safe for concurrent access but consider caching frequently accessed files in memory if you're serving high traffic:
type CachedAssets struct {
fs embed.FS
cache map[string][]byte
mu sync.RWMutex
}
func (c *CachedAssets) ReadFile(name string) ([]byte, error) {
c.mu.RLock()
if data, exists := c.cache[name]; exists {
c.mu.RUnlock()
return data, nil
}
c.mu.RUnlock()
data, err := c.fs.ReadFile(name)
if err != nil {
return nil, err
}
c.mu.Lock()
c.cache[name] = data
c.mu.Unlock()
return data, nil
}
Development vs Production Workflows
Managing embedded assets across different environments requires careful consideration of build processes, development efficiency, and production optimization. The static nature of embedded files creates unique challenges when you need rapid iteration during development.
Hot reloading becomes problematic with embedded assets since they're compiled into your binary. During development, you want immediate feedback when modifying CSS, JavaScript, or templates. The solution involves conditional logic that serves files from disk in development mode and from embedded assets in production:
//go:embed static
var embeddedAssets embed.FS
var isDevelopment = os.Getenv("GO_ENV") == "development"
func getFileSystem() http.FileSystem {
if isDevelopment {
return http.Dir("./static")
}
fsys, err := fs.Sub(embeddedAssets, "static")
if err != nil {
panic(err)
}
return http.FS(fsys)
}
func main() {
http.Handle("/static/", http.StripPrefix("/static/",
http.FileServer(getFileSystem())))
}
This approach lets you modify files during development and see changes immediately without recompiling. However, you must ensure your development directory structure matches your embedded structure exactly, or you'll encounter runtime differences between environments.
Asset versioning becomes critical for cache management in production deployments. Since embedded files can't change without redeployment, you need strategies to handle browser caching and CDN invalidation:
//go:embed static
var staticFiles embed.FS
var assetVersion string
func init() {
// Generate version hash from embedded content
hasher := sha256.New()
fs.WalkDir(staticFiles, "static", func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
content, _ := staticFiles.ReadFile(path)
hasher.Write(content)
}
return nil
})
assetVersion = fmt.Sprintf("%x", hasher.Sum(nil))[:8]
}
func serveWithVersion(w http.ResponseWriter, r *http.Request) {
// Add version to headers for cache busting
w.Header().Set("ETag", `"`+assetVersion+`"`)
w.Header().Set("Cache-Control", "public, max-age=31536000")
// Serve from embedded FS
http.FileServer(http.FS(staticFiles)).ServeHTTP(w, r)
}
Alternatively, you can embed a version file that gets updated during your build process:
//go:embed version.txt
var buildVersion string
func init() {
buildVersion = strings.TrimSpace(buildVersion)
}
Build optimization techniques help manage binary size and compilation time. Use build constraints to exclude development assets from production builds:
//go:build !production
//go:embed static/unminified
var devAssets embed.FS
//go:build production
//go:embed static/minified
var prodAssets embed.FS
//go:build !production
var staticFiles = devAssets
//go:build production
var staticFiles = prodAssets
Your build pipeline can process assets before embedding them. Minify CSS and JavaScript, optimize images, and remove development-only files:
# Build script example
if [ "$GO_ENV" = "production" ]; then
# Minify assets
npm run build:production
# Build with production tag
go build -tags production -ldflags "-s -w" ./cmd/server
else
go build ./cmd/server
fi
Consider using separate binaries for different deployment targets. A development binary might embed source maps and unminified assets, while production builds embed only optimized files. This requires maintaining parallel embed directives but gives you complete control over what gets included in each environment.
For applications with large asset collections, implement lazy loading patterns where you embed only critical assets and load others on demand:
//go:embed critical/*.css critical/*.js
var criticalAssets embed.FS
//go:embed optional
var optionalAssets embed.FS
func serveCritical(w http.ResponseWriter, r *http.Request) {
// Always serve from embedded assets
http.FileServer(http.FS(criticalAssets)).ServeHTTP(w, r)
}
func serveOptional(w http.ResponseWriter, r *http.Request) {
// Check external file system first, fall back to embedded
if isDevelopment {
if _, err := os.Stat("./optional/" + r.URL.Path); err == nil {
http.FileServer(http.Dir("./optional")).ServeHTTP(w, r)
return
}
}
http.FileServer(http.FS(optionalAssets)).ServeHTTP(w, r)
}
Security Considerations
Embedded file systems introduce unique security vectors that differ from traditional file serving approaches. While embedding eliminates many filesystem-based attacks, it creates new concerns around information disclosure and access control.
Embedded file visibility represents the most significant security consideration. Every file you embed becomes part of your binary and can be extracted by anyone with access to the executable. Standard tools like strings
, hexdump
, or specialized binary analysis software can reveal embedded content:
strings your-binary | grep -A5 -B5 "password"
hexdump -C your-binary | grep "secret"
This means you should never embed sensitive information like API keys, passwords, database credentials, or private keys. Even configuration files with default credentials become security risks when embedded:
// DANGEROUS - credentials visible in binary
//go:embed config/database.yaml
var dbConfig []byte
// SAFER - embed template, populate at runtime
//go:embed config/database.template.yaml
var dbTemplate []byte
func loadConfig() {
template := string(dbTemplate)
config := strings.ReplaceAll(template, "{{DB_PASSWORD}}", os.Getenv("DB_PASSWORD"))
// Use populated config
}
Path traversal prevention becomes crucial when serving embedded files through HTTP handlers. Although embed.FS
doesn't provide access outside the embedded tree, improper path handling can still cause issues:
// VULNERABLE - doesn't validate paths
func serveFile(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path[1:] // Remove leading slash
content, err := staticFiles.ReadFile(path)
if err != nil {
http.NotFound(w, r)
return
}
w.Write(content)
}
// SAFER - validate and clean paths
func serveFile(w http.ResponseWriter, r *http.Request) {
path := filepath.Clean(r.URL.Path[1:])
// Ensure path doesn't escape embedded directory
if strings.Contains(path, "..") || strings.HasPrefix(path, "/") {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
content, err := staticFiles.ReadFile(path)
if err != nil {
http.NotFound(w, r)
return
}
w.Write(content)
}
Content validation becomes important when embedding user-generated or external content. Files that seem safe at build time might contain malicious content:
//go:embed user-uploads/*.html
var userContent embed.FS
// RISKY - serving user HTML without validation
func serveUserContent(w http.ResponseWriter, r *http.Request) {
content, err := userContent.ReadFile("user-uploads/" + r.URL.Path)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(content) // Could contain XSS attacks
}
// SAFER - validate and sanitize content
func serveUserContent(w http.ResponseWriter, r *http.Request) {
content, err := userContent.ReadFile("user-uploads/" + r.URL.Path)
if err != nil {
http.NotFound(w, r)
return
}
// Sanitize HTML content
sanitized := bluemonday.UGCPolicy().SanitizeBytes(content)
w.Header().Set("Content-Type", "text/html")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write(sanitized)
}
Information disclosure through error messages can reveal internal structure. Avoid exposing embedded filesystem paths in error responses:
// REVEALS INTERNAL STRUCTURE
func badHandler(w http.ResponseWriter, r *http.Request) {
_, err := staticFiles.ReadFile("internal/config.json")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) // Reveals path
return
}
}
// GENERIC ERROR RESPONSE
func goodHandler(w http.ResponseWriter, r *http.Request) {
_, err := staticFiles.ReadFile("internal/config.json")
if err != nil {
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
}
Access control for embedded resources requires application-level implementation since embed.FS
doesn't provide built-in permissions:
type SecureFileServer struct {
fs embed.FS
allowedPaths map[string]bool
requiredRole string
}
func (s *SecureFileServer) ServeFile(w http.ResponseWriter, r *http.Request, userRole string) {
path := filepath.Clean(r.URL.Path[1:])
// Check path allowlist
if !s.allowedPaths[path] {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Check user permissions
if userRole != s.requiredRole {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
content, err := s.fs.ReadFile(path)
if err != nil {
http.NotFound(w, r)
return
}
w.Write(content)
}
Consider the attack surface of your embedded content. Large embedded filesystems increase binary analysis complexity but also provide more potential targets. Minimize embedded content to only what's necessary for your application to function, and regularly audit embedded files for sensitive information before deployment.
Advanced Techniques
Complex applications often require sophisticated file system patterns that go beyond basic embedding. These advanced techniques provide flexibility while maintaining the performance benefits of embedded assets.
Layered file systems allow you to combine multiple embedded filesystems with runtime priorities. This pattern proves useful when you need to override specific files while maintaining a base set of assets:
//go:embed base
var baseAssets embed.FS
//go:embed overrides
var overrideAssets embed.FS
type LayeredFS struct {
layers []fs.FS
}
func (lfs *LayeredFS) Open(name string) (fs.File, error) {
for _, layer := range lfs.layers {
if file, err := layer.Open(name); err == nil {
return file, nil
}
}
return nil, fs.ErrNotExist
}
func (lfs *LayeredFS) ReadFile(name string) ([]byte, error) {
for _, layer := range lfs.layers {
if data, err := fs.ReadFile(layer, name); err == nil {
return data, nil
}
}
return nil, fs.ErrNotExist
}
func NewLayeredFS() *LayeredFS {
// First layer (overrides) takes priority
return &LayeredFS{
layers: []fs.FS{overrideAssets, baseAssets},
}
}
This approach lets you ship base templates and configurations while allowing environment-specific customizations to take precedence. The layered filesystem checks each layer in order, returning the first match it finds.
Override mechanisms provide runtime flexibility for embedded content. You can implement fallback logic that checks external files before falling back to embedded versions:
type OverrideFS struct {
embedded embed.FS
overrideDir string
}
func (ofs *OverrideFS) ReadFile(name string) ([]byte, error) {
// Try external override first
if ofs.overrideDir != "" {
externalPath := filepath.Join(ofs.overrideDir, name)
if data, err := os.ReadFile(externalPath); err == nil {
return data, nil
}
}
// Fall back to embedded file
return ofs.embedded.ReadFile(name)
}
func (ofs *OverrideFS) Open(name string) (fs.File, error) {
if ofs.overrideDir != "" {
externalPath := filepath.Join(ofs.overrideDir, name)
if file, err := os.Open(externalPath); err == nil {
return file, nil
}
}
return ofs.embedded.Open(name)
}
// Usage
var templateFS = &OverrideFS{
embedded: templateFiles,
overrideDir: os.Getenv("TEMPLATE_OVERRIDE_DIR"),
}
This pattern maintains embedded defaults while allowing operators to customize specific files without recompiling the application. It's particularly valuable for templates, configuration files, and localization resources.
Dynamic content mixing combines embedded static assets with runtime-generated content. This technique works well for applications that need to inject dynamic data into static templates:
//go:embed templates
var templateFS embed.FS
type DynamicRenderer struct {
staticFS embed.FS
dynamicData map[string]interface{}
}
func (dr *DynamicRenderer) RenderTemplate(name string, data interface{}) ([]byte, error) {
// Read base template from embedded FS
tmplContent, err := dr.staticFS.ReadFile("templates/" + name)
if err != nil {
return nil, err
}
// Parse and execute with combined data
tmpl, err := template.New(name).Parse(string(tmplContent))
if err != nil {
return nil, err
}
// Merge static and dynamic data
combinedData := make(map[string]interface{})
for k, v := range dr.dynamicData {
combinedData[k] = v
}
if dataMap, ok := data.(map[string]interface{}); ok {
for k, v := range dataMap {
combinedData[k] = v
}
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, combinedData)
return buf.Bytes(), err
}
Virtual filesystem composition lets you create complex file hierarchies by combining multiple embedded filesystems into a unified view:
type CompositeFS struct {
mounts map[string]fs.FS
}
func (cfs *CompositeFS) Open(name string) (fs.File, error) {
// Find the appropriate mount point
for prefix, mountedFS := range cfs.mounts {
if strings.HasPrefix(name, prefix) {
relativePath := strings.TrimPrefix(name, prefix)
relativePath = strings.TrimPrefix(relativePath, "/")
return mountedFS.Open(relativePath)
}
}
return nil, fs.ErrNotExist
}
func NewCompositeFS() *CompositeFS {
return &CompositeFS{
mounts: map[string]fs.FS{
"static": staticAssets,
"templates": templateAssets,
"config": configAssets,
},
}
}
Content transformation pipelines process embedded files before serving them. This allows you to apply runtime modifications without changing the embedded source:
type TransformFS struct {
source embed.FS
transforms map[string]func([]byte) []byte
}
func (tfs *TransformFS) ReadFile(name string) ([]byte, error) {
data, err := tfs.source.ReadFile(name)
if err != nil {
return nil, err
}
// Apply transformations based on file extension
ext := filepath.Ext(name)
if transform, exists := tfs.transforms[ext]; exists {
data = transform(data)
}
return data, nil
}
// Example usage with CSS minification
var cssFS = &TransformFS{
source: styleAssets,
transforms: map[string]func([]byte) []byte{
".css": func(data []byte) []byte {
// Apply CSS minification
return minifyCSS(data)
},
".js": func(data []byte) []byte {
// Apply JS minification
return minifyJS(data)
},
},
}
These advanced patterns provide powerful capabilities while maintaining the core benefits of embedded filesystems. They let you build flexible, maintainable applications that can adapt to different deployment scenarios without sacrificing performance or reliability.
Top comments (2)
Thanks for the extensive coverage of the FS package.
Go's standard library is the best one across all languages I've dealt with.
Having a single binary of a web app + server with all assets shines in CI/CD pipelines.
yup, FS integ does make builds more portable