oneenv

package module
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: MIT Imports: 13 Imported by: 0

README

oneenv

oneenv

Parse .env files straight into your Go structs — zero dependencies, pure stdlib.

Go Reference Go Report Card CI Go Version License


Table of contents


Why oneenv?

Loading configuration in Go usually takes two separate steps: reading the .env file, and decoding environment variables into a struct. That often means two dependencies, two APIs, and glue code between them.

oneenv does both in one zero-dependency package — parsing and decoding in a single pass over a struct schema that is compiled once and cached — so it stays fast and lightweight.

type Config struct {
    Port    int           `env:"PORT" default:"8080"`
    Host    string        `env:"HOST,required"`
    Timeout time.Duration `env:"TIMEOUT" default:"5s"`
    Tags    []string      `env:"TAGS" separator:","`
    DB      DBConfig      `envPrefix:"DB_"`
}

cfg, err := oneenv.Parse[Config]()
if err != nil {
    log.Fatal(err)
}
fmt.Println(cfg.Port) // 8080

Quick start

package main

import (
    "fmt"
    "log"
    "time"

    "github.com/bakhod1r/oneenv"
)

type Config struct {
    Port    int           `env:"PORT" default:"8080"`
    Host    string        `env:"HOST,required"`
    Timeout time.Duration `env:"TIMEOUT" default:"5s"`
}

func main() {
    // Reads ".env" (if present) and merges it with the process environment.
    cfg, err := oneenv.Parse[Config]()
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%s:%d (timeout %s)\n", cfg.Host, cfg.Port, cfg.Timeout)
}
# .env
HOST=localhost
PORT=9090
TIMEOUT=30s

Features

  • 🪶 Zero dependencies — stdlib only. The whole library is a handful of small files.
  • 🎯 Straight to struct — no os.Getenv boilerplate, no glue between two libraries.
  • Fast — byte-level, allocation-light parser; the struct schema is compiled once and cached, so repeated Loads are nearly free.
  • 🧩 Rich types — ints, floats, bool, time.Duration, time.Time, slices, maps, pointers, nested structs, and any encoding.TextUnmarshaler.
  • 🔐 Secretsenv:"PASSWORD,file" reads a value from a path (Docker/K8s /run/secrets); ,secret + Redacted and Secret[T] keep sensitive values out of logs.
  • 🌱 Env-aware cascadeWithEnvFiles() layers .env, .env.local, .env.<env>, .env.<env>.local like Rails/Next.js.
  • 🧱 Slices of structs — repeated config from indexed keys (SERVER_0_HOST, SERVER_1_HOST, …).
  • 🔄 Hot reloadoneenv/watch re-decodes on file change via native OS events (inotify / kqueue / Windows), still zero-dependency.
  • 🧰 Extensible — custom per-type parsers (WithTypeParser), value mutators (WithMutator), and a pluggable WithValidator — all dependency-free.
  • ↩️ Round-tripsMarshal renders a struct back to .env, and Usage prints a --help table of the variables a struct consumes.
  • 🧪 Hermetic tests — a Lookuper interface means no global state and no t.Setenv; parallel-safe by design.
  • 🧯 Great errors — positioned parse errors (file:line) and all field failures collected at once via errors.Join.
  • 🔁 Familiar API — low-level Read / LoadEnv / Overload and a rich, conventional struct-tag vocabulary.

Install

go get github.com/bakhod1r/oneenv

Requires Go 1.26+.

Loading configuration

oneenv gives you a small, layered API. Pick the entry point that fits.

Parse — allocate and decode

Parse[T] is the generic convenience form: it allocates a T, decodes into it, and returns the pointer.

cfg, err := oneenv.Parse[Config]()                       // reads the default ".env"

// Point it at one or more files — later files override earlier keys:
cfg, err := oneenv.Parse[Config](
    oneenv.WithFiles(".env", ".env.local", ".env.production"),
)
Load — decode into an existing value
var cfg Config
err := oneenv.Load(&cfg, oneenv.WithFiles(".env", ".env.local"))

v must be a non-nil pointer to a struct. Both Parse and Load are safe for concurrent use.

Unmarshal — decode raw bytes

Decodes .env-formatted bytes directly, without touching any file or the process environment. Handy for embedded configs or tests.

data := []byte("PORT=9090\nHOST=localhost")
var cfg Config
err := oneenv.Unmarshal(data, &cfg)
LoadContext / ParseContext — thread a context

Use these when you register mutators that need a context.Context.

cfg, err := oneenv.ParseContext[Config](ctx, oneenv.WithMutator(resolveSecret))
Low-level API

When you only need the raw values, not a struct:

vals, _ := oneenv.Read(".env", ".env.local")   // merge files → map[string]string
_ = oneenv.LoadEnv(".env", ".env.local")        // sets os.Setenv (existing wins)
_ = oneenv.Overload(".env", ".env.local")       // sets os.Setenv (.env wins)

Read, LoadEnv and Overload are variadic — pass as many .env files as you like; later files override earlier keys. With no arguments they default to .env.

Options

oneenv uses the functional-options pattern — the zero config is already sensible, options are applied in order, and a later option wins over an earlier one.

Option Description
WithFiles(names...) .env files to read (default .env); later files override earlier keys. A missing default .env is not an error.
WithEnvFiles() Enable the environment-aware cascade: also read <base>.local, <base>.<env>, <base>.<env>.local (all optional).
WithEnvVar(names...) Which env variables name the active environment for WithEnvFiles (default APP_ENV, then GO_ENV).
WithPrefix(p) Restrict lookups to keys carrying a prefix, e.g. APP_ maps env:"PORT" to APP_PORT.
WithOverride() Let .env values overwrite variables already in the process env (default: existing wins).
WithExpand() Enable ${VAR} / $VAR expansion inside values.
WithRequired() Treat every field as required, as if each carried ,required.
WithTagKey(k) Change the struct tag key (default env).
WithLookuper(l) Swap the env source — pass a MapLookuper for hermetic tests.
WithTypeParser[T](fn) Register a custom parser for a specific type T.
WithMutator(m) Transform each raw value before decoding (receives a context.Context).
WithValidator(fn) Run a validation callback on the decoded struct — plug in any validator, zero-dep.
WithContext(ctx) Context passed to mutators (also via LoadContext / ParseContext).

Struct tags

type Config struct {
    Port     int           `env:"PORT" default:"8080" desc:"listen port"`
    Host     string        `env:"HOST,required"`
    Tags     []string      `env:"TAGS" separator:","`
    Labels   map[string]int `env:"LABELS"`               // KEY:VALUE pairs, comma-separated
    Started  time.Time     `env:"STARTED" layout:"2006-01-02"`
    Password string        `env:"PASSWORD,file"`          // value read from the file at this path
    Token    string        `env:"TOKEN,notEmpty"`         // present but empty is an error
    Ignored  string        `env:"-"`                      // never populated
    DB       DBConfig      `envPrefix:"DB_"`              // nested struct
}
Tag reference
Tag Applies to Meaning
env:"NAME" any field Environment key. Defaults to the Go field name if omitted. env:"-" skips the field.
env:"NAME,required" any field Fail if the value is absent from every source.
env:"NAME,notEmpty" any field Fail if the value is present but empty.
env:"NAME,file" string-ish Treat the resolved value as a path and read the file's contents as the real value.
env:"NAME,init" pointer / slice / map Allocate a non-nil zero value even when no value is supplied.
env:"NAME,unset" any field Remove the variable from the process environment after reading it.
env:"NAME,secret" any field Mask the value in Redacted / RedactedMap output (plain Marshal keeps it).
default:"..." any field Fallback value when nothing else provides one.
separator:"," slice / map Element separator. envSeparator is accepted as an alias.
layout:"..." time.Time time.Parse layout (default time.RFC3339).
envPrefix:"DB_" nested struct Prefix applied to every key inside the nested struct.
desc:"..." any field Human description, surfaced by Usage.

Multiple options combine: env:"TOKEN,required,file" reads a required secret file.

env-* tag aliases

Every configuration tag also has an env-* spelling, so an env-prefixed convention can be used throughout:

Native env-* alias
default:"8080" env-default:"8080"
separator:"," (or envSeparator) env-separator:","
desc:"..." env-description:"..."
layout:"..." env-layout:"..."
envPrefix:"DB_" env-prefix:"DB_"
env:"NAME,required" env-required:"true"
env:"NAME,notEmpty" env-notempty:"true"
env:"NAME,file" env-file:"true"
env:"NAME,init" env-init:"true"
env:"NAME,unset" env-unset:"true"
type Config struct {
    Port int      `env:"PORT" env-default:"8080" env-description:"listen port"`
    Tags []string `env:"TAGS" env-separator:";"`
    Host string   `env:"HOST" env-required:"true"`
    DB   DBConfig `env-prefix:"DB_"`
}

Priority when both spellings are present: the env-* form always wins; the native tag is the fallback. A boolean env-* tag like env-required:"false" explicitly turns the option off.

Resolution priority

For each field the value is resolved in this order:

  1. Explicit environment variable (via the Lookuper, default os.LookupEnv)
  2. .env file value
  3. default tag

With WithOverride(), .env file values take precedence over the process environment. A field with no value from any source and no default is left at its zero value — unless it is required.

Supported types

Category Types
Strings string
Booleans bool (strconv.ParseBool: 1, t, true, TRUE, …)
Integers int, int8int64, uint, uint8uint64
Floats float32, float64
Time time.Duration ("5s", "1h30m"), time.Time (RFC3339 or layout tag)
Collections []T (any supported T), map[string]T (key:value pairs)
Pointers *T for any supported T (allocated only when a value is present)
Nested structs (recursed into, with optional envPrefix)
Custom any encoding.TextUnmarshaler, or any type via WithTypeParser

Slices and maps use the field's separator (default ,); map entries are key:value. For example LABELS=a:1,b:2 decodes into map[string]int{"a":1,"b":2}.

.env file syntax

oneenv supports the syntax you expect from a mature .env parser:

# A comment line.
export PATH_STYLE=ok            # "export " prefix is allowed; inline comments too

PLAIN=value
QUOTED="double quoted"          # escapes: \n \r \t \" \\
RAW='single quoted'             # no escapes, no expansion — taken literally
MULTILINE="line one
line two"                       # newlines allowed inside double quotes

GREETING="Hello ${USER}"        # ${VAR} / $VAR expansion — only with WithExpand()
LITERAL='$NOT_EXPANDED'         # single quotes never expand
  • Comments — a # starting a line, or preceded by whitespace on a value line.
  • export prefix — accepted and ignored, so you can source the same file.
  • Quotes — double quotes honour escapes and can span multiple lines; single quotes are literal.
  • Expansion${VAR} and $VAR are expanded (when WithExpand() is set) against values already parsed in the file, falling back to the process environment. Write $$ for a literal $.

Syntax errors come back as a *ParseError carrying the file name and line number.

Secrets from files

Containers and orchestrators mount secrets as files (/run/secrets/..., Kubernetes secret volumes). Add ,file and oneenv reads the file's contents as the value — the environment variable holds the path, not the secret itself.

type Config struct {
    DBPassword string `env:"DB_PASSWORD,file"`
}
DB_PASSWORD=/run/secrets/db_password

A trailing newline in the file is trimmed. If the file can't be read, the field error wraps ErrSecretFile. Combine with default to provide a fallback path, or with required to insist the secret exists.

Masking secrets in output

Two ways to keep sensitive values out of logs, dumps and --help output.

,secret tag + Redacted. Mark a field secret and render the struct with Redacted (or RedactedMap); the mask replaces only the marked values, while plain Marshal still emits the real value.

type Config struct {
    Host     string `env:"HOST"`
    Password string `env:"PASSWORD,secret"`
}

out, _ := oneenv.Redacted(cfg)
// HOST=db
// PASSWORD=****

Secret[T] wrapper. Wrap any decodable type; its String, %v/%#v and JSON forms are always masked, so it can't leak through logging by accident. The real value is available via .Value().

type Config struct {
    APIKey oneenv.Secret[string] `env:"API_KEY"`
}

fmt.Println(cfg.APIKey)         // ****
client.Auth(cfg.APIKey.Value()) // the real key

Environment-aware file cascade

WithEnvFiles() layers files by the active environment, the convention used by Rails, Next.js and dotenv-cli. On top of each base file it also reads <base>.local, <base>.<env> and <base>.<env>.local, each optional, later files overriding earlier keys:

cfg, err := oneenv.Parse[Config](oneenv.WithEnvFiles())
// with APP_ENV=production, reads in increasing priority:
//   .env  →  .env.local  →  .env.production  →  .env.production.local

The environment name comes from APP_ENV, then GO_ENV; change the sources with WithEnvVar("MY_ENV"). FilesFor(opts...) returns the resolved list.

Slices of structs

A []Struct field is decoded from indexed keys: the field's env name, then _<index>_, then the element's key. Decoding starts at index 0 and stops at the first index with no keys present.

type Server struct {
    Host string `env:"HOST"`
    Port int    `env:"PORT"`
}
type Config struct {
    Servers []Server `env:"SERVER"`
}
SERVER_0_HOST=a
SERVER_0_PORT=1
SERVER_1_HOST=b
SERVER_1_PORT=2

Hot reload

The oneenv/watch subpackage re-decodes your struct whenever a watched .env file changes. It uses native OS notifications — inotify on Linux, kqueue on BSD/macOS and ReadDirectoryChangesW on Windows — with modification-time polling as a fallback on any other platform. All standard library, so the zero-dependency guarantee still holds.

import "github.com/bakhod1r/oneenv/watch"

var cfg Config
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Blocks until ctx is cancelled. Read cfg inside onReload (or guard it with a
// mutex): Watch writes cfg concurrently with your readers.
watch.Watch(ctx, &cfg, func(err error) {
    if err != nil {
        log.Printf("reload failed: %v", err)
        return
    }
    log.Printf("config reloaded")
}, oneenv.WithEnvFiles())

Custom type parsers

Register a parser for any specific type without implementing TextUnmarshaler. It also applies to that type inside slices, maps and pointers.

import "net"

cfg, err := oneenv.Parse[Config](
    oneenv.WithTypeParser(func(s string) (net.IP, error) {
        return net.ParseIP(s), nil
    }),
)

Note: registering any type parser bypasses the shared schema cache for that call, since the same type could decode differently between calls. Register your parsers once and reuse the option set to keep things fast.

Mutators

A Mutator transforms every raw value after lookup and before decoding. Mutators run in registration order, each receiving the previous one's output and a context.Context. Perfect for resolving indirections (secret managers, templating) or normalising values.

cfg, err := oneenv.ParseContext[Config](ctx,
    oneenv.WithMutator(func(ctx context.Context, key, val string) (string, error) {
        if ref, ok := strings.CutPrefix(val, "sm://"); ok {
            return secretmanager.Resolve(ctx, ref)   // your code
        }
        return val, nil
    }),
)

Returning an error from a mutator fails that field and is reported like any other field error.

Validation

oneenv stays dependency-free, so it ships no validator — but WithValidator lets you attach any one you like. It runs once, on the fully decoded struct, after a successful decode.

import "github.com/go-playground/validator/v10"

v := validator.New()
cfg, err := oneenv.Parse[Config](
    oneenv.WithValidator(func(c any) error { return v.Struct(c) }),
)

Marshal — struct back to .env

Marshal renders a struct into .env bytes (sorted KEY=value lines, values quoted and escaped when needed). MarshalMap returns the flat map[string]string instead. Prefixes from nested structs are applied, so the output round-trips through Unmarshal.

data, _ := oneenv.Marshal(cfg)
os.Stdout.Write(data)
// DB_HOST=localhost
// DB_PORT=5432
// NAME="app one"
// TAGS=a,b

m, _ := oneenv.MarshalMap(cfg)   // map[string]string

Usage — generate --help

Usage[T] writes a table of the variables a struct consumes — key, type, whether it's required, its default, and the desc tag — ideal for a --help flag.

oneenv.Usage[Config](os.Stdout)
KEY   TYPE           REQUIRED  DEFAULT  DESCRIPTION
PORT  int            no        8080     listen port
HOST  string         yes                bind host

Example — generate .env.example

Example[T] writes a ready-to-fill .env.example for the variables a struct consumes: each key with its default (empty when none), preceded by the desc tag, the type, and whether it's required. Secret defaults are never written.

oneenv.Example[Config](os.Stdout)
# listen port
# type: int
PORT=8080

# bind host
# type: string, required
HOST=

The CLI can also produce one from your existing .env files — keys are kept, values stripped:

oneenv -example            # writes .env.example next to you
oneenv -f .env -example -o -   # print to stdout

Testing without global state

The decoder never touches os.Getenv directly — everything flows through a Lookuper. In tests, pass a MapLookuper and skip os.Setenv / t.Setenv entirely, so tests stay hermetic and t.Parallel()-safe.

func TestConfig(t *testing.T) {
    t.Parallel()

    lookuper := oneenv.MapLookuper{"PORT": "3000", "HOST": "test"}

    var cfg Config
    if err := oneenv.Load(&cfg, oneenv.WithLookuper(lookuper)); err != nil {
        t.Fatal(err)
    }
    // assert on cfg…
}

Error handling

A single Load reports every missing or malformed variable at once (joined via errors.Join), not one at a time — so you fix your config in one pass.

if err := oneenv.Load(&cfg); err != nil {
    var pe *oneenv.ParseError
    if errors.As(err, &pe) {
        fmt.Printf("syntax error at %s:%d — %s\n", pe.File, pe.Line, pe.Msg)
    }

    var fe *oneenv.FieldError
    if errors.As(err, &fe) {
        fmt.Printf("field %s (env %q) failed: %v\n", fe.Field, fe.Key, fe.Err)
    }
}
Sentinel errors

Match the cause with errors.Is:

Sentinel Returned when
ErrNotAStruct The target isn't a non-nil pointer to a struct.
ErrRequired A required field has no value from any source.
ErrEmpty A notEmpty field is present but empty.
ErrSecretFile A file field names a path that can't be read.
ErrUnsupportedType A field has a type oneenv can't decode.
Error types
  • *ParseError — a syntax error in a source, with File, Line and Msg.
  • *FieldError — a decode failure, with the struct Field path (e.g. DB.Port), the env Key, and the underlying Err (Unwrap-able).

Non-goals

To stay fast and dependency-free, oneenv deliberately does not ship:

  • File watching / live reload — no background file-watching machinery. Re-Load when you need fresh values.
  • Multiple config formats (yaml/json/toml) — oneenv is .env-only by design.
  • A bundled validation library — use WithValidator to attach your own.

Benchmarks

The full pipeline — turn a .env file into a populated config struct — against six popular libraries. Every row does the same end-to-end work on the same config; env-only decoders are paired with godotenv to read the file (the usual real-world combo). Correctness is asserted before timing. Apple M4 Pro, Go 1.26, -count=5 medians:

Library ns/op B/op allocs/op vs oneenv
oneenv 708 872 20 1.0×
godotenv + sethvargo/go-envconfig 1,810 960 35 2.6×
godotenv + joeshaw/envdecode 2,337 1,617 45 3.3×
godotenv + ilyakaznacheev/cleanenv 3,747 3,592 85 5.3×
godotenv + kelseyhightower/envconfig 3,914 2,952 121 5.5×
spf13/viper 7,479 12,359 135 10.6×
godotenv + caarlos0/env v11 7,539 15,374 147 10.6×

oneenv is the fastest and lightest of the group — 2.6–10.6× faster with the fewest allocations (up to 17× less memory than caarlos0/env), because it parses and decodes in a single pass over a struct schema that is compiled once and cached. Reproduce:

cd internal/bench && go test -bench . -benchmem -count=5

The comparison lives in its own module, so the root package stays dependency-free.

License

MIT © bakhod1r

Documentation

Overview

Package oneenv reads .env files and decodes them straight into Go structs, with zero external dependencies.

It combines what most projects reach for two libraries to do — a dotenv file parser and an environment-to-struct decoder — behind a single, fast, cached API.

Quick start

type Config struct {
    Port    int           `env:"PORT" default:"8080"`
    Host    string        `env:"HOST,required"`
    Timeout time.Duration `env:"TIMEOUT" default:"5s"`
    Tags    []string      `env:"TAGS" separator:","`
    DB      DBConfig      `envPrefix:"DB_"`
}

cfg, err := oneenv.Parse[Config](oneenv.WithFiles(".env", ".env.local"))

Resolution order

Each field is resolved with the priority: process environment, then .env file value, then the `default` tag. WithOverride flips file values above the environment.

Options

Behavior is tuned with functional options: WithFiles, WithEnvFiles, WithEnvVar, WithPrefix, WithOverride, WithExpand, WithRequired, WithTagKey, WithLookuper, WithTypeParser, WithMutator, WithValidator and WithContext. The zero configuration is valid, and later options win over earlier ones.

Environment-aware file cascade

WithEnvFiles layers files by the active environment, like Rails and Next.js: on top of each base file it also reads <base>.local, <base>.<env> and <base>.<env>.local (all optional, later files winning). The environment name comes from APP_ENV then GO_ENV, configurable with WithEnvVar.

Secrets

The ",secret" tag option (or env-secret:"true") marks a field sensitive: Redacted and RedactedMap render its value as a mask while Marshal keeps the real value. Alternatively wrap a field in Secret[T], whose String, GoString and JSON forms are always masked, so it never leaks through logging.

Slices of structs

A []Struct field is decoded from indexed keys. A field tagged env:"SERVER" reads SERVER_0_HOST, SERVER_1_HOST, ... one element per index, stopping at the first index with no keys present.

Hot reload

The subpackage oneenv/watch re-decodes a struct whenever a watched .env file changes, using native OS notifications — inotify (Linux), kqueue (BSD/macOS) and ReadDirectoryChangesW (Windows), with modification-time polling as a fallback on other platforms — still with zero external dependencies.

Tags

env:"NAME"           bind to env key NAME
env:"NAME,required"  error if unset from every source
env:"NAME,notEmpty"  error if present but empty
env:"NAME,file"      read the value from the file at the resolved path
env:"NAME,init"      allocate a nil pointer/slice/map even when unset
env:"NAME,unset"     drop the variable from the environment after reading
env:"NAME,secret"    mask the value in Redacted output
env:"-"              skip the field
default:"value"      fallback when unset
separator:";"        element delimiter for slices and maps (default ",")
envSeparator:";"     alias for separator
layout:"2006-01-02"  time.Time parse layout (default RFC3339)
envPrefix:"DB_"      prefix applied to a nested struct's keys
desc:"..."           description surfaced by Usage

Every configuration tag also has an env-* spelling (env-default, env-separator, env-description, env-layout, env-prefix, env-required, env-notempty, env-file, env-init, env-unset and env-secret). When both spellings are present the env-* form takes priority and the native tag is the fallback.

Marshal and Usage

Marshal renders a struct back into .env bytes, and Usage writes a table of the variables a struct consumes for --help output.

Performance

The parser scans source bytes once without regexp, and struct schemas are analyzed once per type and cached, so repeated Load calls avoid reflection overhead. All exported functions are safe for concurrent use.

Example

Example shows the common case: parse configuration straight into a struct.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Port int      `env:"PORT" default:"8080"`
		Host string   `env:"HOST,required"`
		Tags []string `env:"TAGS" separator:","`
	}

	cfg, err := oneenv.Parse[Config](
		oneenv.WithLookuper(oneenv.MapLookuper{"HOST": "localhost", "TAGS": "a,b,c"}),
	)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s:%d %v\n", cfg.Host, cfg.Port, cfg.Tags)
}
Output:
localhost:8080 [a b c]

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotAStruct is returned when the decode target is not a non-nil pointer
	// to a struct.
	ErrNotAStruct = errors.New("oneenv: target must be a non-nil pointer to struct")

	// ErrRequired is returned (wrapped in a FieldError) when a field marked
	// required has no value from any source.
	ErrRequired = errors.New("oneenv: required variable is not set")

	// ErrUnsupportedType is returned (wrapped in a FieldError) when a struct
	// field has a type that oneenv cannot decode into.
	ErrUnsupportedType = errors.New("oneenv: unsupported field type")

	// ErrEmpty is returned (wrapped in a FieldError) when a field marked
	// ",notEmpty" resolves to an empty value.
	ErrEmpty = errors.New("oneenv: variable is set but empty")

	// ErrSecretFile is returned (wrapped in a FieldError) when a field marked
	// ",file" names a path that cannot be read.
	ErrSecretFile = errors.New("oneenv: cannot read secret file")
)

Sentinel errors returned by the package. Compare against them with errors.Is.

Functions

func Example added in v1.1.0

func Example[T any](w io.Writer, opts ...Option) error

Example writes a ready-to-fill .env.example file for the environment variables that a struct of type T consumes. Each key is preceded by a comment carrying its description, Go type, and whether it is required; the value is the default when one is declared, and empty otherwise. Secret fields never leak their default value.

oneenv.Example[Config](os.Stdout)

func FilesFor added in v1.1.0

func FilesFor(opts ...Option) []string

FilesFor returns the ordered list of .env file paths that Load would read for the given options, including the full environment-aware cascade when WithEnvFiles is set. It lets tooling (such as oneenv/watch) discover which files to observe without reimplementing the resolution rules.

func Load

func Load(v any, opts ...Option) error

Load reads the configured .env files (default ".env"), merges them with the process environment, and decodes the result into the struct pointed to by v.

v must be a non-nil pointer to a struct. Values are resolved per field with the priority: process environment > .env file > `default` tag. Every field error is collected and returned joined via errors.Join.

Load is safe for concurrent use.

func LoadContext

func LoadContext(ctx context.Context, v any, opts ...Option) error

LoadContext behaves like Load but threads ctx through to any mutators registered with WithMutator.

func LoadEnv

func LoadEnv(filenames ...string) error

LoadEnv parses the given .env files and sets each variable into the process environment via os.Setenv. Existing variables are preserved (call with WithOverride semantics is not available here; use Overload for that).

func Marshal

func Marshal(v any, opts ...Option) ([]byte, error)

Marshal renders v as .env-formatted bytes (KEY=value lines, sorted by key). It is the inverse of Unmarshal for the fields oneenv understands. Values that contain whitespace, quotes or newlines are double-quoted and escaped.

Example

ExampleMarshal renders a struct back into .env bytes.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Host string `env:"HOST"`
		Port int    `env:"PORT"`
	}

	data, err := oneenv.Marshal(Config{Host: "localhost", Port: 5432})
	if err != nil {
		panic(err)
	}
	fmt.Print(string(data))
}
Output:
HOST=localhost
PORT=5432

func MarshalMap

func MarshalMap(v any, opts ...Option) (map[string]string, error)

MarshalMap renders v into a flat key/value map, applying any configured prefix so the keys match what Load would look up.

func Overload

func Overload(filenames ...string) error

Overload behaves like LoadEnv but overwrites variables that already exist in the process environment.

func Parse

func Parse[T any](opts ...Option) (*T, error)

Parse is the generic convenience form of Load: it allocates a T, decodes into it, and returns the pointer.

cfg, err := oneenv.Parse[Config](oneenv.WithPrefix("APP_"))
Example
package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Name string `env:"NAME"`
	}

	cfg, err := oneenv.Parse[Config](
		oneenv.WithFiles(),
		oneenv.WithLookuper(oneenv.MapLookuper{"NAME": "myapp"}),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(cfg.Name)
}
Output:
myapp
Example (Defaults)

ExampleParse_defaults shows how the `default` tag fills a field when no source provides a value. Anything present in the environment or file wins over the default.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Host string `env:"HOST" default:"localhost"`
		Port int    `env:"PORT" default:"8080"`
	}

	cfg, err := oneenv.Parse[Config](
		oneenv.WithLookuper(oneenv.MapLookuper{"PORT": "9090"}), // HOST falls back
	)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s:%d\n", cfg.Host, cfg.Port)
}
Output:
localhost:9090
Example (EnvTags)

ExampleParse_envTags shows the env-* tag aliases, which work alongside the native tags and take priority when both are present.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Port int      `env:"PORT" env-default:"8080"`
		Tags []string `env:"TAGS" env-separator:";"`
	}

	cfg, err := oneenv.Parse[Config](
		oneenv.WithLookuper(oneenv.MapLookuper{"TAGS": "a;b;c"}),
	)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%d %v\n", cfg.Port, cfg.Tags)
}
Output:
8080 [a b c]
Example (ErrorHandling)

ExampleParse_errorHandling shows how a single Parse reports every problem, each retrievable as a typed *FieldError.

package main

import (
	"errors"
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Host string `env:"HOST,required"`
	}

	_, err := oneenv.Parse[Config](oneenv.WithLookuper(oneenv.MapLookuper{}))

	var fe *oneenv.FieldError
	if errors.As(err, &fe) {
		fmt.Printf("%s is required\n", fe.Key)
	}
}
Output:
HOST is required
Example (Nested)

ExampleParse_nested decodes a nested struct. The envPrefix tag prefixes every key inside it, so DB.Host reads from DB_HOST.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type DB struct {
		Host string `env:"HOST"`
		Port int    `env:"PORT"`
	}
	type Config struct {
		Name string `env:"NAME"`
		DB   DB     `envPrefix:"DB_"`
	}

	cfg, err := oneenv.Parse[Config](oneenv.WithLookuper(oneenv.MapLookuper{
		"NAME":    "api",
		"DB_HOST": "db.internal",
		"DB_PORT": "5432",
	}))
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s -> %s:%d\n", cfg.Name, cfg.DB.Host, cfg.DB.Port)
}
Output:
api -> db.internal:5432
Example (SliceOfStructs)

ExampleParse_sliceOfStructs decodes a repeated struct from indexed keys.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Server struct {
		Host string `env:"HOST"`
		Port int    `env:"PORT"`
	}
	type Config struct {
		Servers []Server `env:"SERVER"`
	}

	cfg, err := oneenv.Parse[Config](oneenv.WithLookuper(oneenv.MapLookuper{
		"SERVER_0_HOST": "a", "SERVER_0_PORT": "1",
		"SERVER_1_HOST": "b", "SERVER_1_PORT": "2",
	}))
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s:%d %s:%d\n", cfg.Servers[0].Host, cfg.Servers[0].Port, cfg.Servers[1].Host, cfg.Servers[1].Port)
}
Output:
a:1 b:2
Example (Time)

ExampleParse_time parses a time.Time with a custom layout.

package main

import (
	"fmt"
	"time"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Release time.Time `env:"RELEASE" layout:"2006-01-02"`
	}

	cfg, err := oneenv.Parse[Config](
		oneenv.WithLookuper(oneenv.MapLookuper{"RELEASE": "2026-07-18"}),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(cfg.Release.Year())
}
Output:
2026
Example (Types)

ExampleParse_types shows the built-in support for durations, booleans, slices and maps. Slices split on the separator; maps take key:value pairs.

package main

import (
	"fmt"
	"time"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Debug   bool           `env:"DEBUG"`
		Timeout time.Duration  `env:"TIMEOUT"`
		Tags    []string       `env:"TAGS" separator:","`
		Limits  map[string]int `env:"LIMITS" separator:","` // key:value,key:value
	}

	cfg, err := oneenv.Parse[Config](oneenv.WithLookuper(oneenv.MapLookuper{
		"DEBUG":   "true",
		"TIMEOUT": "1m30s",
		"TAGS":    "web,api,worker",
		"LIMITS":  "cpu:8,mem:16",
	}))
	if err != nil {
		panic(err)
	}
	fmt.Printf("debug=%t timeout=%s tags=%v cpu=%d\n",
		cfg.Debug, cfg.Timeout, cfg.Tags, cfg.Limits["cpu"])
}
Output:
debug=true timeout=1m30s tags=[web api worker] cpu=8

func ParseContext

func ParseContext[T any](ctx context.Context, opts ...Option) (*T, error)

ParseContext is the generic convenience form of LoadContext.

func Read

func Read(filenames ...string) (map[string]string, error)

Read parses one or more .env files and returns the merged key/value map, without writing anything to the process environment. Later files override earlier keys.

Example

ExampleRead parses a .env file into a plain map without touching the process environment or a struct.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/bakhod1r/oneenv"
)

func main() {
	dir, _ := os.MkdirTemp("", "oneenv")
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, ".env")
	_ = os.WriteFile(path, []byte("HOST=localhost\nPORT=5432\n"), 0o600)

	m, err := oneenv.Read(path)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s:%s\n", m["HOST"], m["PORT"])
}
Output:
localhost:5432

func Redacted added in v1.1.0

func Redacted(v any, opts ...Option) ([]byte, error)

Redacted renders v as .env-formatted bytes like Marshal, but replaces the value of every field marked ",secret" (or env-secret:"true") with a mask. Use it to log or print a configuration without leaking sensitive values.

Example

ExampleRedacted renders a struct to .env bytes with ",secret" fields masked, so a configuration can be logged without leaking sensitive values.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Host     string `env:"HOST"`
		Password string `env:"PASSWORD,secret"`
	}

	cfg := Config{Host: "db", Password: "hunter2"}
	data, err := oneenv.Redacted(cfg)
	if err != nil {
		panic(err)
	}
	fmt.Print(string(data))
}
Output:
HOST=db
PASSWORD=****

func RedactedMap added in v1.1.0

func RedactedMap(v any, opts ...Option) (map[string]string, error)

RedactedMap is MarshalMap with secret field values masked.

Example

ExampleRedactedMap masks secret fields while returning a map, handy for structured logging.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		User     string `env:"USER"`
		Password string `env:"PASSWORD,secret"`
	}

	m, err := oneenv.RedactedMap(Config{User: "admin", Password: "hunter2"})
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s / %s\n", m["USER"], m["PASSWORD"])
}
Output:
admin / ****

func Unmarshal

func Unmarshal(data []byte, v any, opts ...Option) error

Unmarshal decodes .env-formatted bytes directly into v, without touching any file or the process environment. Options such as WithExpand and WithPrefix still apply.

Example
package main

import (
	"fmt"
	"time"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Host    string        `env:"HOST" default:"localhost"`
		Port    int           `env:"PORT" default:"8080"`
		Timeout time.Duration `env:"TIMEOUT" default:"5s"`
	}

	src := []byte("HOST=example.com\nPORT=9090")

	var cfg Config
	if err := oneenv.Unmarshal(src, &cfg); err != nil {
		panic(err)
	}
	fmt.Printf("%s:%d timeout=%s\n", cfg.Host, cfg.Port, cfg.Timeout)
}
Output:
example.com:9090 timeout=5s

func Usage

func Usage[T any](w io.Writer, opts ...Option) error

Usage writes a human-readable table of the environment variables that a struct of type T consumes: the key, Go type, whether it is required, its default, and the "desc" tag. It is handy for --help output.

oneenv.Usage[Config](os.Stdout)
Example

ExampleUsage prints a table of the variables a struct consumes, handy for a --help flag.

package main

import (
	"os"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Port int    `env:"PORT" default:"8080" desc:"listen port"`
		Host string `env:"HOST,required" desc:"bind address"`
	}

	_ = oneenv.Usage[Config](os.Stdout)
}
Output:
KEY   TYPE    REQUIRED  DEFAULT  DESCRIPTION
PORT  int     no        8080     listen port
HOST  string  yes                bind address

Types

type FieldError

type FieldError struct {
	Field string // Go struct field path, e.g. "DB.Port"
	Key   string // env key, e.g. "DB_PORT"
	Err   error  // underlying cause
}

FieldError associates a decoding failure with the struct field and env key that produced it. Retrieve it from a Load error with errors.As.

func (*FieldError) Error

func (e *FieldError) Error() string

func (*FieldError) Unwrap

func (e *FieldError) Unwrap() error

type Lookuper

type Lookuper interface {
	// Lookup returns the value for key and whether it was present.
	Lookup(key string) (value string, ok bool)
}

Lookuper is the source of environment values consulted by the decoder. The decoder never touches os.Getenv directly; everything flows through a Lookuper, which makes tests hermetic and parallel-safe.

type MapLookuper

type MapLookuper map[string]string

MapLookuper reads from an in-memory map. Handy in tests.

func (MapLookuper) Lookup

func (m MapLookuper) Lookup(key string) (string, bool)

Lookup implements Lookuper.

type Mutator

type Mutator func(ctx context.Context, key, value string) (string, error)

Mutator transforms a raw value after lookup and before it is decoded into a field. Mutators are applied in registration order; each receives the output of the previous one. A non-nil error aborts that field's decoding.

type OSLookuper

type OSLookuper struct{}

OSLookuper reads from the process environment via os.LookupEnv.

func (OSLookuper) Lookup

func (OSLookuper) Lookup(key string) (string, bool)

Lookup implements Lookuper.

type Option

type Option func(*config)

Option configures a Load, Parse or Read call. Options follow the functional options pattern: each Option is a function that mutates an internal config. The zero config is valid and sensible, options are applied in order, and a later option overrides an earlier one (last-wins).

func WithContext

func WithContext(ctx context.Context) Option

WithContext sets the context passed to mutators. Defaults to context.Background.

func WithEnvFiles added in v1.1.0

func WithEnvFiles() Option

WithEnvFiles enables the dotenv-style, environment-aware file cascade. On top of each configured base file (default ".env"), oneenv also reads, in increasing priority: "<base>.local", "<base>.<env>" and "<base>.<env>.local", where <env> is the active environment name (see WithEnvVar). Every file in the cascade is optional. This mirrors the layered .env convention used by Rails, Next.js and dotenv-cli.

Example

ExampleWithEnvFiles shows the environment-aware cascade: on top of the base file it also reads <base>.<env>, so with APP_ENV=production the production override wins.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/bakhod1r/oneenv"
)

func main() {
	dir, _ := os.MkdirTemp("", "oneenv")
	defer os.RemoveAll(dir)
	base := filepath.Join(dir, ".env")
	_ = os.WriteFile(base, []byte("HOST=localhost\nPORT=8080\n"), 0o600)
	_ = os.WriteFile(base+".production", []byte("HOST=prod.internal\n"), 0o600)

	type Config struct {
		Host string `env:"HOST"`
		Port int    `env:"PORT"`
	}

	cfg, err := oneenv.Parse[Config](
		oneenv.WithFiles(base),
		oneenv.WithEnvFiles(),
		oneenv.WithLookuper(oneenv.MapLookuper{"APP_ENV": "production"}),
	)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s:%d\n", cfg.Host, cfg.Port)
}
Output:
prod.internal:8080

func WithEnvVar added in v1.1.0

func WithEnvVar(names ...string) Option

WithEnvVar sets which environment variables name the active environment for WithEnvFiles, consulted in order (first non-empty wins). Defaults to APP_ENV then GO_ENV.

func WithExpand

func WithExpand() Option

WithExpand enables ${VAR} and $VAR expansion inside values.

Example
package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		URL string `env:"URL"`
	}

	src := []byte("HOST=db\nURL=postgres://${HOST}:5432")

	var cfg Config
	if err := oneenv.Unmarshal(src, &cfg, oneenv.WithExpand()); err != nil {
		panic(err)
	}
	fmt.Println(cfg.URL)
}
Output:
postgres://db:5432

func WithFiles

func WithFiles(names ...string) Option

WithFiles sets the .env files to read, replacing the default ".env". Files are read in order; later files override earlier keys.

func WithLookuper

func WithLookuper(l Lookuper) Option

WithLookuper replaces the environment source used during decoding. Defaults to OSLookuper. Pass a MapLookuper for hermetic tests.

func WithMutator

func WithMutator(m Mutator) Option

WithMutator registers a Mutator applied to every field's raw value before it is decoded. Multiple mutators run in registration order.

Example

ExampleWithMutator transforms every value before it is decoded.

package main

import (
	"context"
	"fmt"
	"strings"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Name string `env:"NAME"`
	}

	cfg, err := oneenv.ParseContext[Config](context.Background(),
		oneenv.WithLookuper(oneenv.MapLookuper{"NAME": "app"}),
		oneenv.WithMutator(func(_ context.Context, _, v string) (string, error) {
			return strings.ToUpper(v), nil
		}),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(cfg.Name)
}
Output:
APP

func WithOverride

func WithOverride() Option

WithOverride lets values from .env files overwrite variables that already exist in the process environment. By default existing variables win.

func WithPrefix

func WithPrefix(prefix string) Option

WithPrefix restricts environment lookups to keys carrying the given prefix. For example WithPrefix("APP_") maps a field tagged env:"PORT" to APP_PORT.

Example

ExampleWithPrefix restricts every lookup to a prefix, so env:"PORT" reads from APP_PORT. Handy when one process hosts several components.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Port int `env:"PORT"`
	}

	cfg, err := oneenv.Parse[Config](
		oneenv.WithPrefix("APP_"),
		oneenv.WithLookuper(oneenv.MapLookuper{"APP_PORT": "9090"}),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(cfg.Port)
}
Output:
9090

func WithRequired

func WithRequired() Option

WithRequired treats every field as required, as if each carried the ",required" tag option.

func WithTagKey

func WithTagKey(key string) Option

WithTagKey overrides the struct tag key used for field names (default "env").

func WithTypeParser

func WithTypeParser[T any](fn func(string) (T, error)) Option

WithTypeParser registers a custom parser for a specific type T. Whenever a field of type T is decoded, fn is used instead of the built-in setter. This works for named types, structs, or any type not otherwise supported.

oneenv.WithTypeParser(func(s string) (net.IP, error) {
    return net.ParseIP(s), nil
})

Registering any type parser disables the shared schema cache for that call, so prefer registering parsers once and reusing the option set.

Example

ExampleWithTypeParser registers a parser for a type that has no built-in support (here net.IP). It also applies inside slices, maps and pointers.

package main

import (
	"fmt"
	"net"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Addr net.IP `env:"ADDR"`
	}

	cfg, err := oneenv.Parse[Config](
		oneenv.WithLookuper(oneenv.MapLookuper{"ADDR": "10.0.0.1"}),
		oneenv.WithTypeParser(func(s string) (net.IP, error) { return net.ParseIP(s), nil }),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(cfg.Addr)
}
Output:
10.0.0.1

func WithValidator

func WithValidator(fn func(v any) error) Option

WithValidator registers a function called with the fully decoded target once decoding succeeds. Returning an error fails the Load. This keeps oneenv dependency-free while letting callers plug in any validation library.

Example

ExampleWithValidator runs a validation callback on the decoded struct, letting you plug in any validator without adding a dependency.

package main

import (
	"errors"
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		Port int `env:"PORT"`
	}

	_, err := oneenv.Parse[Config](
		oneenv.WithLookuper(oneenv.MapLookuper{"PORT": "70000"}),
		oneenv.WithValidator(func(v any) error {
			if v.(*Config).Port > 65535 {
				return errors.New("port out of range")
			}
			return nil
		}),
	)
	fmt.Println(err)
}
Output:
port out of range

type ParseError

type ParseError struct {
	File string // file name, empty for in-memory sources
	Line int    // 1-based line number
	Msg  string // human readable message
}

ParseError describes a syntax error in a .env source, with position.

func (*ParseError) Error

func (e *ParseError) Error() string

type PrefixLookuper

type PrefixLookuper struct {
	Prefix string
	Next   Lookuper
}

PrefixLookuper strips a fixed prefix off every key before delegating.

func (PrefixLookuper) Lookup

func (p PrefixLookuper) Lookup(key string) (string, bool)

Lookup implements Lookuper.

type Secret added in v1.1.0

type Secret[T any] struct {
	// contains filtered or unexported fields
}

Secret wraps a sensitive configuration value of type T. It decodes exactly like a bare T (reusing oneenv's setters), but its String, GoString and MarshalJSON representations are masked, so a Secret never leaks through fmt, log, %v/%+v/%#v or encoding/json. Retrieve the real value with Value.

type Config struct {
    APIKey oneenv.Secret[string] `env:"API_KEY"`
}
fmt.Println(cfg.APIKey)        // ****
client.Use(cfg.APIKey.Value()) // the real key
Example

ExampleSecret wraps a sensitive value so it never prints in the clear, while the real value stays available through Value.

package main

import (
	"fmt"

	"github.com/bakhod1r/oneenv"
)

func main() {
	type Config struct {
		APIKey oneenv.Secret[string] `env:"API_KEY"`
	}

	cfg, err := oneenv.Parse[Config](
		oneenv.WithLookuper(oneenv.MapLookuper{"API_KEY": "s3cr3t"}),
	)
	if err != nil {
		panic(err)
	}
	fmt.Printf("masked=%v real=%s\n", cfg.APIKey, cfg.APIKey.Value())
}
Output:
masked=**** real=s3cr3t

func NewSecret added in v1.1.0

func NewSecret[T any](v T) Secret[T]

NewSecret wraps v in a Secret.

func (Secret[T]) GoString added in v1.1.0

func (s Secret[T]) GoString() string

GoString returns the mask for %#v formatting.

func (Secret[T]) MarshalJSON added in v1.1.0

func (s Secret[T]) MarshalJSON() ([]byte, error)

MarshalJSON masks the value so it never leaks through encoding/json.

func (Secret[T]) MarshalText added in v1.1.0

func (s Secret[T]) MarshalText() ([]byte, error)

MarshalText renders the real underlying value, so Marshal round-trips a Secret back to its plaintext form in a .env file. Use Redacted to mask it.

func (Secret[T]) String added in v1.1.0

func (s Secret[T]) String() string

String returns the mask, satisfying fmt.Stringer.

func (*Secret[T]) UnmarshalText added in v1.1.0

func (s *Secret[T]) UnmarshalText(text []byte) error

UnmarshalText decodes raw into the underlying T using oneenv's built-in setter machinery, so Secret[T] supports every type oneenv can decode.

func (Secret[T]) Value added in v1.1.0

func (s Secret[T]) Value() T

Value returns the unmasked underlying value.

Directories

Path Synopsis
cmd
oneenv command
Command oneenv is a small CLI around the oneenv package.
Command oneenv is a small CLI around the oneenv package.
examples
basic command
Example: load a .env file straight into a struct.
Example: load a .env file straight into a struct.
Package watch adds hot-reloading to oneenv: it re-decodes your configuration whenever a watched .env file changes on disk.
Package watch adds hot-reloading to oneenv: it re-decodes your configuration whenever a watched .env file changes on disk.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL