Skip to content

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.

// 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.

A value can reference another value, anywhere in the document, resolved after the whole document is parsed. Forward references work.

host = "localhost"
port = 8080
base-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 = info
log-level = ${?LOG_LEVEL} // stays "info" when LOG_LEVEL is unset

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 element
extras = [a]
extras += b
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, d
retry-delay = 500ms
max-upload = 64MiB // and K, KB, KiB, M, MB, MiB, G, ...
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 auth module keeps auth { … } next to its code, with every default filled in. The application never copies those defaults; it brings them in with one include.
  • 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 auth and 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 team
auth {
session { lifetime = 30m, max-body = 64KiB }
# db: no default; the application supplies auth.db
}
# application.conf: owned by the application
include package("github.com/acme/auth", "reference.conf")
shared.db { host = db.internal, port = 5432 }
auth.db = ${shared.db} # wiring lives in the application
auth.session.lifetime = 2h # override what differs; the rest stays the module's default

Inside 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.

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.

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.

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.