A Lightbend HOCON specification
parser for Python. Hand-written lexer, recursive-descent parser, and a typed Config
API. Zero runtime dependencies (pure stdlib), Python 3.11+, fully typed (py.typed).
See Spec Compliance for the current conformance rate.
Implemented by Claude (Anthropic) — designed and built end-to-end with Claude Code, ported from the sibling ts.hocon implementation.
Library stance — py.hocon is a HOCON config loader. Its purpose is reading
.confconfig files and providing typed access via theConfigAPI (get_string,get_number,get_boolean,get_duration,get_bytes,get_period,to_object). It is not a low-level parser API — internal types underhocon._internalmay change between minor versions.Cross-language conformance — This implementation is tested against shared expected-JSON fixtures from o3co/xx.hocon alongside ts.hocon, go.hocon, and rs.hocon, ensuring all four implementations meet the same Lightbend HOCON specification.
pip install hocon-parserThe import name is hocon:
import hoconimport hocon
cfg = hocon.parse("""
server {
host = "localhost"
port = 8080
}
database {
url = "jdbc:postgresql://localhost/mydb"
pool-size = 10
}
""")
cfg.get_string("server.host") # "localhost"
cfg.get_int("server.port") # 8080
cfg.has("server.host") # True.env |
JSON | YAML | HOCON | |
|---|---|---|---|---|
| Comments | No | No | Yes | Yes |
| Nesting | No | Yes | Yes | Yes |
| References / Substitution | No | No | No | Yes (${var}) |
| File inclusion | No | No | No | Yes (include) |
| Object merging | No | No | Anchors (fragile) | Yes (deep merge) |
| Optional values | No | No | No | Yes (${?var}) |
| Trailing commas | N/A | No | N/A | Yes |
| Unquoted strings | Yes | No | Yes | Yes |
HOCON isn't just a serialization format — it's a config-injection language.
JSON, YAML, and TOML describe data structures and leave file layering, environment
variables, and reference resolution to your code (Pydantic, attrs, etc.). HOCON
bakes those into the spec itself: by the time your program reads the config,
fallback files are merged and ${VAR} references resolved into a single composed
object. Conditional branching from "is this value present in this layer?"
disappears at the format boundary.
On top of that, HOCON combines the readability of YAML with the structure of JSON — making it a strong fit for anything beyond flat key-value config.
- Full HOCON parsing: objects, arrays, scalars, substitutions (
${path},${?path}) - Self-referential substitutions (
path = ${path}:/extra) with cycle detection - Deep-merge for duplicate keys (last definition wins)
+=append operatorincludedirectives:include "file.conf",include file("..."),include package("id", "file"), andinclude required(...)wrappers- Triple-quoted strings (
"""...""") - Duration, period, and byte-size parsing (
get_duration(),get_period(),get_bytes()) - Environment-variable substitution (
${HOME}) and env-var list expansion (${NAME[]}→NAME_0,NAME_1, …) - Numerically-keyed object → array conversion
.propertiesincludes- Deferred resolution lifecycle:
parse(..., resolve_substitutions=False)→with_fallback→resolve()(LightbendparseString/withFallback/resolve()API) - Zero runtime dependencies (pure stdlib), fully typed (
py.typed)
import hocon
hocon.parse(text, *, base_dir=None, env=None, read_file=None,
resolve_substitutions=True, origin_description=None,
resolve_from=None, package_resolver=None) -> hocon.Config
hocon.parse_string(text, **opts) # alias of parse()
hocon.parse_file(path, **opts) # resolves includes relative to the file's dirParse options (keyword-only):
| Option | Type | Description |
|---|---|---|
base_dir |
str |
Base directory for include resolution |
env |
dict[str, str] |
Environment variables for substitution (default: os.environ) |
read_file |
(str) -> str |
Custom file reader |
resolve_substitutions |
bool |
Resolve substitutions during parse (default True); False returns a deferred Config |
origin_description |
str |
Source name surfaced in error messages |
resolve_from / package_resolver |
— | Control include package(...) resolution |
All typed getters raise on failure; paths use dot notation, with quoted
segments for keys that contain dots (config.get_string('"a.b".c')).
| Method | Returns | Raises if |
|---|---|---|
get(path) |
value or None |
— |
get_string(path) |
str |
missing, wrong type, or unresolved |
get_number(path) |
int | float |
missing, not numeric, or unresolved |
get_int(path) |
int |
missing, not numeric, or unresolved |
get_float(path) |
float |
missing, not numeric, or unresolved |
get_boolean(path) |
bool |
missing, wrong type, or unresolved |
get_duration(path, unit=None) |
float |
missing, wrong type, or invalid duration |
get_bytes(path, unit=None) |
float |
missing, wrong type, or invalid byte size |
get_period(path) |
Period |
missing, wrong type, or invalid period |
get_config(path) |
Config |
missing, not an object, or unresolved |
get_list(path) |
list |
missing, not an array, or unresolved |
get_value(path) |
HoconValue | None |
subtree unresolved |
has(path) |
bool |
— |
keys() |
list[str] |
— |
with_fallback(fallback) |
Config |
— |
resolve(*, allow_unresolved=False, use_system_environment=True) |
Config |
unresolvable substitution (unless allow_unresolved) |
resolve_with(source, *, ...) |
Config |
source unresolved, or unresolvable substitution |
is_resolved() |
bool |
— |
to_object() |
dict / list / scalar |
— |
get_boolean also accepts yes/no and on/off. get_number returns an
int for integral lexemes and a float otherwise.
from hocon import from_map, empty
cfg = from_map({"server": {"host": "localhost", "port": 8080}})
cfg.get_int("server.port") # 8080
empty() # a resolved Config with no keysKeys in from_map are treated as plain keys, not path expressions —
{"a.b": 1} produces a top-level key literally named a.b.
Beyond the decoded to_object() / get(), get_value() exposes the raw value
tree for introspection via standalone accessors:
from hocon import as_string, as_object, is_scalar, is_null
node = cfg.get_value("server") # HoconValue
as_object(node) # dict[str, HoconValue] | None
is_scalar(cfg.get_value("server.port")) # TrueSeparate parse, fallback-layering, and resolve for runtime config injection:
import hocon
from hocon import from_map
# 1. Parse without resolving — substitutions deferred
cfg = hocon.parse(
'version = ${shortversion}-${CI_RUN_NUMBER}\n'
'variables { shortversion = "1.2.3" }',
resolve_substitutions=False,
)
cfg.is_resolved() # False — ${CI_RUN_NUMBER} still pending
# 2. Layer runtime fallbacks
runtime = from_map({"CI_RUN_NUMBER": "42"})
variables = cfg.get_config("variables")
merged = cfg.with_fallback(runtime).with_fallback(variables)
# 3. Resolve the full fallback stack
resolved = merged.resolve(use_system_environment=False)
resolved.get_string("version") # "1.2.3-42"resolve_with resolves the receiver using a source for lookup without merging
the source's keys into the result:
receiver = hocon.parse("r = ${key}", resolve_substitutions=False)
source = from_map({"key": "val"})
result = receiver.resolve_with(source)
result.has("key") # False — source keys excluded
result.get_string("r") # "val"from hocon import (
ParseError, # lexing/parsing failure: .line, .col, .file
ResolveError, # substitution/include failure: .path, .line, .col, .file
PackageLookupError, # include package(...) not found (subclass of ResolveError)
ConfigError, # wrong type or missing path: .path
NotResolvedError, # getter on an unresolved path (subclass of ConfigError)
)| Type | When |
|---|---|
ParseError |
Syntax errors during lexing/parsing (includes line and column) |
ResolveError |
Substitution failures, cyclic references, missing required includes |
PackageLookupError |
include package(...) could not be located |
ConfigError |
Missing keys or type mismatches during value access; also raised by parse/parse_file for an array-root document (S3.5) |
NotResolvedError |
Getter called on a path still holding an unresolved substitution |
# Comments with # or //
database {
host = "db.example.com"
port = 5432
url = "jdbc:"${database.host}":"${database.port}
}
# Duplicate keys deep-merge (last wins for scalars)
server { host = localhost }
server { port = 8080 } // result: { host: "localhost", port: 8080 }
# Self-referential append
path = "/usr/bin"
path = ${path}":/usr/local/bin"
# += shorthand
items = [1]
items += 2
items += 3 // [1, 2, 3]
# Include
include "defaults.conf"
include file("overrides.conf")
# Triple-quoted multiline strings
description = """
This is a
multiline string.
"""
# Unquoted strings
path = /usr/local/binfrom hocon import Period
c = hocon.parse("""
timeout = "30s"
cache-ttl = "5m"
retention = "2w"
max-size = "512MiB"
""")
c.get_duration("timeout") # 30000.0 (ms)
c.get_duration("timeout", "s") # 30.0
c.get_duration("cache-ttl", "m") # 5.0
c.get_period("retention") # Period(years=0, months=0, days=14)
c.get_bytes("max-size") # 536870912 (bytes)
c.get_bytes("max-size", "MiB") # 512.0Supported duration units: ns, us, ms, s, m, h, d (and long forms
like seconds, minutes). Duration unit names are case-sensitive and must be
lowercase (HOCON spec S19.8). Byte units are more case-tolerant: the canonical
forms plus lowercase aliases (kb, kib, …), any-case long forms (megabytes),
and single-letter powers-of-two in both cases (K/k, per Lightbend, S21.4).
Supported byte units: B, KB/KiB, MB/MiB, GB/GiB, TB/TiB.
get_period (spec S20.1–S20.4) returns a Period(years, months, days) value —
a frozen dataclass mirroring rs.hocon's Period struct. Supported units:
d/day/days (default for bare numbers), w/week/weeks (folded into
days), m/mo/month/months, y/year/years — lowercase only, like
durations. Periods are integer-only (Lightbend Integer.parseInt):
fractional values such as "7.5" raise ConfigError, unlike get_duration /
get_bytes which accept them. Negative periods are permitted.
Indicative timings from benchmarks/bench.py (each iteration parses and does a
get_string lookup). Run make bench to reproduce on your machine.
| Scenario | ops/sec | Time per op |
|---|---|---|
| Small config (10 keys) | ~5,700 | ~175 µs |
| Medium config (100 keys) | ~600 | ~1.7 ms |
| Large config (1,000 keys) | ~57 | ~17.7 ms |
| 10 substitutions | ~3,700 | ~270 µs |
| 50 substitutions | ~800 | ~1.2 ms |
| 100 substitutions | ~400 | ~2.5 ms |
| Depth 5 nesting | ~8,500 | ~117 µs |
| Depth 10 nesting | ~4,600 | ~220 µs |
| Depth 20 nesting | ~2,000 | ~500 µs |
As a pure-Python parser, py.hocon is roughly 30–40× slower than the compiled /
V8-backed siblings (go.hocon, rs.hocon, ts.hocon). For typical application configs
(loaded once at startup), the cost is negligible — even a 1,000-key config parses
in under 20 ms. If you parse very large configs on a hot path, cache the resulting
Config.
Conformance against the Lightbend HOCON specification
is tracked at item granularity in docs/spec-compliance.md;
see xx.hocon/docs/compliance-matrix.md
for live cross-impl values.
| Corpus | py.hocon | go.hocon / rs.hocon (reference) |
|---|---|---|
| Spec corpus (134) | 134 (100.0%) | 134 (100.0%) |
| Lightbend suite (16) | 14/16 | 14/16 |
At parity with the reference sibling implementations. The 2 held-out Lightbend
fixtures reference JVM system properties (${?java.version} / ${?user.home}),
which resolve only inside a JVM — every non-JVM parser caps at 14/16.
| Project | Language | Registry | Description |
|---|---|---|---|
| ts.hocon | TypeScript | npm | HOCON parser for TypeScript/Node.js |
| go.hocon | Go | pkg.go.dev | HOCON parser for Go |
| rs.hocon | Rust | crates.io | HOCON parser for Rust |
| hocon2 | Go | pkg.go.dev | HOCON → JSON/YAML/TOML/Properties CLI |
All four parser implementations are tracked against the same Lightbend HOCON spec — see the cross-impl roll-up for per-impl conformance rates.
- Split by domain: separate configuration into logical units (
database.conf,server.conf,logging.conf) - Use
includefor composition: compose a full config from domain-specific files - Avoid logic in config: HOCON is for declarative data, not conditionals or computation
- Minimize
${ENV}usage: prefer${?ENV}(optional) with sensible defaults defined in the config itself - Never require env vars for local development: defaults should work out of the box
- Document required env vars: list them in your project's README or a
.env.example - Bulk-mounting a namespace:
hocon.adapters.envmaps a whole prefix into a subtree (APP_DB__HOST→db.host);__is the only hierarchy boundary — see Format adapters
config/
├── application.conf # shared defaults
├── dev.conf # include "application.conf" + dev overrides
└── prod.conf # include "application.conf" + prod overrides
Validate config at application startup, not at point-of-use. Load into a typed
structure (dataclass, attrs, or Pydantic) so errors surface early:
from dataclasses import dataclass
import hocon
@dataclass
class ServerConfig:
host: str
port: int
cfg = hocon.parse_file("application.conf")
server = ServerConfig(
host=cfg.get_string("server.host"),
port=cfg.get_int("server.port"),
) # fails fast on startup if a field is missing or the wrong typeConfig files that belong to other programs can be mounted as HOCON, so a
${...} in your document can reach into them:
import hocon
from hocon.adapters import env
base = env.load(prefix="APP_") # APP_DB__HOST -> db.host
cfg = hocon.parse(src, resolve_substitutions=False)
merged = cfg.with_fallback(base).resolve()Deferring resolution matters: the plain parse resolves as it goes, so a
${...} aimed at the fallback would fail before the fallback is attached.
| Module | Extra needed | Notes |
|---|---|---|
hocon.adapters.properties |
— | java.util.Properties, sharing the include syntax layer |
hocon.adapters.env |
— | Bulk-mounts a prefixed namespace; also reads .env |
hocon.adapters.jsonc |
— | JSON with comments and trailing commas |
hocon.adapters.toml |
— | via tomllib, which Python 3.11 ships |
hocon.adapters.yaml |
pip install hocon-parser[yaml] |
via ruamel.yaml |
Only YAML needs a dependency; the base install stays pure standard library.
Plain JSON needs no adapter — HOCON is a JSON superset, so hocon.parse
accepts it as it stands.
Foreign data stays data: a ${a.b} in a mounted value is literal text, never a
reference, because the file belongs to a program that never agreed to HOCON's
syntax.
env — __ is the only hierarchy boundary. A . inside a variable name is
key text, not a nesting level, so the two spellings are different paths:
cfg = env.load("APP_", {"APP_FOO__BAR": "nested", "APP_FOO.BAR": "literal"})
cfg.get_string("foo.bar") # "nested" — from APP_FOO__BAR
cfg.get_string('"foo.bar"') # "literal" — from APP_FOO.BAR, one quoted keyBecause they are different paths they never collide. A genuine collision
(APP_A__B and APP_a__b, which lowercase onto the same path) is an error:
the environment has no order to break the tie with. A .env file does have a
definite order, so there the last line simply wins. Segments are lowercased
ASCII-only — A–Z and nothing else, so the mapping is identical in all
four implementations rather than each language's Unicode rules — and a single
_ stays inside the segment (APP_DB__MAX_CONN → db.max_conn).
env — an entry that is not valid UTF-8 never becomes a value. Python
decodes the environment with surrogateescape, so undecodable bytes survive as
lone surrogates that raise only when something later encodes them. What happens
to such an entry depends on whether you asked for it: ${VAR} and ${?VAR}
treat it as absent (the optional form falls through to its default, the
required form raises the usual unresolved error), while env.load(prefix=...)
errors if the entry is inside the prefix you mounted — a mount that dropped
it silently would look complete while the operator's setting was missing.
Entries outside the prefix are never inspected, so an undecodable variable
elsewhere cannot break your mount.
jsonc — comments separate tokens. A comment is removed by replacing it with
whitespace, so {"a": 1/*x*/2} is a syntax error rather than {"a": 12}. A
// comment ends at LF or CR, matching the dialect VS Code reads.
properties — every key is an ordinary key. __proto__, constructor and
prototype are kept with their values like any other key; a Python dict has
no prototype to pollute, so nothing is filtered out.
all formats — integers are int64, and a BOM is not key text. An ingested
integer outside [-2^63, 2^63-1] is an error rather than a value the other
implementations could not hold, and a leading UTF-8 BOM is stripped by every
parse_file helper instead of ending up inside the first key.
For YAML, scalar resolution belongs to the library, not to this package.
ruamel.yaml reads YAML 1.2, so no stays the string "no"; PyYAML is YAML
1.1 and would resolve it to False — the Norway problem — which is why it is
not the default. A caller who wants a different library decodes the text
themselves and hands the tree over:
import yaml as pyyaml
from hocon.adapters.yaml import from_value
cfg = from_value(pyyaml.safe_load(src), "their-file.yml")include url(...)is not supported. Fetching remote configuration is outside the scope of this parser — fetch the content with your HTTP client, then pass it toparse().include classpath(...)is not supported. This is a JVM-specific include form with no equivalent outside Java runtimes.include package(...)resolves via a filesystem convention (searchresolve_from/base_dir/ CWD for<base>/<id>/<file>), not a Python package-manager lookup. Supply a custompackage_resolverfor other schemes.- No watch/reload — the library parses config at load time. For live-reloading,
re-call
parse()/parse_file()on change. - No streaming parser — the entire input is loaded into memory. For untrusted input, validate size before parsing (see Security Considerations).
When parsing untrusted HOCON input, be aware of:
- Path traversal in includes:
include "../../../etc/passwd"resolves relative tobase_dir. Supply a customread_filethat validates paths if parsing untrusted input. - Input size: the parser has no built-in input size limit. For untrusted input,
validate size before calling
parse(). - Include depth: limited to 50 levels to prevent stack overflow from deep include chains.
- Mapped path depth: an environment variable's
__segments and a Properties file's dotted key are limited to 64 segments. One name produces one arbitrarily deep chain, so without the cap 1.5 kB of variable name was enough to exhaust the interpreter's stack. rs.hocon caps the same mapping at 64. - Document nesting depth: not capped. A document nested past what the
interpreter's stack holds fails as
ParseErrororAdapterError— never as a bareRecursionError— but the depth at which that happens depends on how deep the calling code already is, so validate size before parsing untrusted input (see above) rather than relying on a fixed level.
make setup # create .venv and install dev dependencies (needs python3.11+)
make check # ruff + mypy --strict + pytest
make bench # run the micro-benchmarks
make testdata # sync the conformance corpus from o3co/xx.hoconSee CONTRIBUTING.md for the full workflow.
Apache License 2.0 — see LICENSE.
Copyright 2026 1o1 Co. Ltd.
Designed and built end-to-end with Claude Code, ported from ts.hocon.