Skip to content

Repository files navigation

py.hocon — HOCON Parser for Python

PyPI Python CI codecov License Spec conformance

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 .conf config files and providing typed access via the Config API (get_string, get_number, get_boolean, get_duration, get_bytes, get_period, to_object). It is not a low-level parser API — internal types under hocon._internal may 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.


Quick Start

1. Install

pip install hocon-parser

The import name is hocon:

import hocon

2. Use

import 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

Why HOCON?

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

Features

  • 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 operator
  • include directives: include "file.conf", include file("..."), include package("id", "file"), and include 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
  • .properties includes
  • Deferred resolution lifecycle: parse(..., resolve_substitutions=False)with_fallbackresolve() (Lightbend parseString / withFallback / resolve() API)
  • Zero runtime dependencies (pure stdlib), fully typed (py.typed)

API Reference

Parse functions

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 dir

Parse 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

Config methods

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.

Value factories

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 keys

Keys in from_map are treated as plain keys, not path expressions — {"a.b": 1} produces a top-level key literally named a.b.

Structural access

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"))   # True

Deferred resolution

Separate 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"

Error Types

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

HOCON Examples

# 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/bin

Durations, Periods, and Byte Sizes

from 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.0

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

Performance

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.

Spec Compliance

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.

Related Projects

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.

Best Practices

Config Structure

  • Split by domain: separate configuration into logical units (database.conf, server.conf, logging.conf)
  • Use include for composition: compose a full config from domain-specific files
  • Avoid logic in config: HOCON is for declarative data, not conditionals or computation

Environment Variables

  • 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.env maps a whole prefix into a subtree (APP_DB__HOSTdb.host); __ is the only hierarchy boundary — see Format adapters

Dev / Prod Separation

config/
├── application.conf    # shared defaults
├── dev.conf            # include "application.conf" + dev overrides
└── prod.conf           # include "application.conf" + prod overrides

Validation

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 type

Format adapters

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

Mapping rules worth knowing

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 key

Because 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-onlyAZ 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_CONNdb.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")

Known Limitations

  • 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 to parse().
  • 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 (search resolve_from / base_dir / CWD for <base>/<id>/<file>), not a Python package-manager lookup. Supply a custom package_resolver for 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).

Security Considerations

When parsing untrusted HOCON input, be aware of:

  • Path traversal in includes: include "../../../etc/passwd" resolves relative to base_dir. Supply a custom read_file that 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 ParseError or AdapterError — never as a bare RecursionError — 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.

Development

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

See CONTRIBUTING.md for the full workflow.

License

Apache License 2.0 — see LICENSE.

Copyright 2026 1o1 Co. Ltd.

Attribution

Designed and built end-to-end with Claude Code, ported from ts.hocon.

About

Full Lightbend HOCON specification-compliant parser for Python (sibling of o3co go.hocon / ts.hocon / rs.hocon)

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages