What is HOCON?
HOCON (Human-Optimized Config Object Notation) is a superset of JSON designed by Lightbend for
configuration files. A JSON document whose root is an object or an array is also a valid HOCON
document, so adopting it does not invalidate the configuration files you already have. (A bare
scalar such as 42 is valid JSON but not a HOCON document.) The specification lives in
HOCON.md and is the sole reference
these implementations are measured against.
What it adds
Section titled “What it adds”Comments, and fewer punctuation rules
Section titled “Comments, and fewer punctuation rules”// Both comment styles work# so do these
app { name = my-service // '=' or ':' — both are assignment port = 8080 // no trailing comma required tags = [a, b, c] // and unquoted strings are ordinary values}Keys and string values do not need quotes unless they contain characters that would otherwise be syntax. Quoting is still available and still means exactly what it means in JSON.
Substitution
Section titled “Substitution”A value can reference another value, anywhere in the document, resolved after the whole document is parsed. Forward references work.
host = "localhost"port = 8080base-url = "http://"${host}":"${port} // "http://localhost:8080"${?name} is the optional form. If the path does not resolve, the assignment simply does not
happen — which is what makes environment overrides work without a templating layer:
log-level = infolog-level = ${?LOG_LEVEL} // stays "info" when LOG_LEVEL is unsetMerging, and self-reference
Section titled “Merging, and self-reference”Objects deep-merge. When the same key appears twice, the later one wins for scalars and merges for objects.
server { host = localhost, port = 8080 }server { port = 9090 }// server = { host: "localhost", port: 9090 }A key may refer to its own previous value, which is how you append to a list without repeating it:
path = [/usr/bin]path = ${path} [/usr/local/bin]
// += is shorthand for appending one elementextras = [a]extras += bIncludes
Section titled “Includes”include "defaults.conf"include required(file("secrets.conf"))Included documents merge into the point where they appear, so an include at the top provides defaults and one at the bottom overrides.
The specification also defines include url(...) and include classpath(...). None of the four
implementations supports either: fetching remote configuration belongs to your HTTP client, and
the classpath is a JVM concept. Fetch the content yourself and parse it.
Durations and byte sizes are part of the language, so the parser hands you a typed value (a number in a chosen unit, or the language’s own duration type) and not a string to interpret yourself.
timeout = 30s // also: ms, minutes, h, dretry-delay = 500msmax-upload = 64MiB // and K, KB, KiB, M, MB, MiB, G, ...Multi-line strings
Section titled “Multi-line strings”query = """ SELECT * FROM users WHERE active = true"""Configuration that stays inside its module
Section titled “Configuration that stays inside its module”In a large codebase, configuration tends to leak: one big file that every team edits, defaults copied into each service, and modules that read each other’s keys. HOCON lets each module own its configuration the way it owns its code.
- Each module owns a namespace and ships its own defaults. The
authmodule keepsauth { … }next to its code, with every default filled in. The application never copies those defaults; it brings them in with oneinclude. - The application overrides only what differs. Later layers deep-merge over earlier ones, so the application states two keys, not the whole block.
- Each module reads only its own subtree, through its own schema. The module asks for
authand validates it against the schema it defines. The shape of that section is the module’s contract; nothing else in the file is visible to it. - Wiring between modules lives in the application. The module only declares the keys it
reads (
auth.db) and leaves them without a default when the application must supply them. The application connects them to shared values (auth.db = ${shared.db}). Modules never reference each other or the application’s keys, so dependencies run one way.
# auth/reference.conf: shipped inside the auth package, owned by the auth teamauth { session { lifetime = 30m, max-body = 64KiB } # db: no default; the application supplies auth.db}# application.conf: owned by the applicationinclude package("github.com/acme/auth", "reference.conf")
shared.db { host = db.internal, port = 5432 }auth.db = ${shared.db} # wiring lives in the applicationauth.session.lifetime = 2h # override what differs; the rest stays the module's defaultInside the module, the read is one call against one path, and the units arrive as typed values.
If the application leaves auth.db unwired, that call fails and names the missing db, instead
of handing the module a half-filled section:
import { getValidated, hocon } from '@o3co/ts.hocon/zod'import type { Config } from '@o3co/ts.hocon'import { z } from 'zod'
const Auth = z.object({ session: z.object({ lifetime: hocon.duration('s').pipe(z.number().int().positive()), // "2h" → 7200 'max-body': hocon.bytes().pipe(z.number().int().positive()), // "64KiB" → 65536 }), db: z.object({ host: z.string(), port: z.number().int() }),})
export const loadAuth = (config: Config) => getValidated(config, 'auth', Auth)zod is an optional peer dependency of @o3co/ts.hocon; install it next to it to use the
@o3co/ts.hocon/zod entry point. ts.hocon’s default package resolver hands the identifier to
Node module resolution, so there it is an npm package name such as @acme/auth; a Go-style
identifier such as github.com/acme/auth needs a packageResolver that maps it to the package.
// Auth is the module's contract: the shape of the auth section.type Auth struct { Session struct { Lifetime time.Duration `hocon:"lifetime"` // "2h" MaxBody hocon.ByteSize `hocon:"max-body"` // "64KiB" → 65536 } `hocon:"session"` DB struct { Host string `hocon:"host"` Port int `hocon:"port"` } `hocon:"db"` // no default: the application must supply auth.db}
// Load reads the auth subtree and nothing else.func Load(root *hocon.Config) (Auth, error) { var auth Auth if err := root.UnmarshalPath("auth", &auth); err != nil { return auth, err } if auth.Session.Lifetime <= 0 { return auth, fmt.Errorf("auth.session.lifetime must be positive, got %v", auth.Session.Lifetime) } return auth, nil}use serde::Deserialize;use std::time::Duration;
#[derive(Deserialize)]#[serde(rename_all = "kebab-case")]pub struct Session { #[serde(with = "hocon::serde::duration")] pub lifetime: Duration, // "2h" #[serde(with = "hocon::serde::bytes")] pub max_body: u64, // "64KiB" → 65536}
#[derive(Deserialize)]pub struct Db { pub host: String, pub port: u16 }
#[derive(Deserialize)]pub struct Auth { pub session: Session, pub db: Db }
pub fn load(config: &hocon::Config) -> Result<Auth, hocon::ConfigError> { config.get_as::<Auth>("auth")}This needs the crate’s serde and include-package features, and your own serde dependency
with derive (cargo add hocon-parser --features serde,include-package and
cargo add serde --features derive). See the
Serde section of the Rust README.
from dataclasses import dataclassfrom typing import Annotated
import hocon
@dataclassclass Session: lifetime: Annotated[int, hocon.Duration("s")] # "2h" → 7200 max_body: Annotated[int, hocon.Bytes()] # reads max-body; "64KiB" → 65536
@dataclassclass Db: host: str port: int
@dataclassclass Auth: session: Session db: Db # no default: the application must supply auth.db
def load_auth(config: hocon.Config) -> Auth: return config.decode(Auth, "auth")include package(...) is not part of HOCON.md: it is a convention shared by the o3co
implementations (E11),
a non-JVM counterpart to include classpath(...). It is how a module publishes its defaults
without the application knowing where its files live. How a package’s file is found is per
language — see ts.hocon (packageResolver),
go.hocon (RegisterPackage),
rs.hocon (Parser::register_package)
and py.hocon (resolve_from /
package_resolver). On the JVM, Lightbend’s reference.conf convention plays the same role.
What tends to surprise people
Section titled “What tends to surprise people”Concatenation is real. a = foo bar is the single string "foo bar", not an error. Two
adjacent arrays concatenate; two adjacent objects merge. This is what makes
"http://"${host} work, and it is also why an accidental space in a value is not always a
syntax error.
Whitespace inside unquoted values is preserved between tokens but trimmed at the edges. When in doubt, quote.
Duplicate keys are a feature. No implementation will warn you that you set port twice —
setting it twice is how overriding works.
Resolution happens after parsing. A substitution that cannot be resolved is an error at resolve time, not at parse time, and the error names the path that failed rather than a line number in the middle of a merge.
Where to go next
Section titled “Where to go next”The quick start has the first program in each of the four languages. The compliance page records exactly which parts of the specification each implementation covers, item by item.