TOML, INI, .env and the Rest: Which Configuration Format When?
Choose by who reads the file: if .NET is the only reader, JSON and environment variables are enough; if several tools read the same .env, write it in the strictest common subset. We read one .env with Compose, Node.js, dotenv, python-dotenv and bash, one INI with .NET, Python and git, and show the differences, TOML 1.1's date types and Bookstore.Api's setting in five formats becoming one configuration.
Version note: The examples in this post were run with .NET 10 (Microsoft.Extensions.Configuration packages 10.0.15, NetEscapades.Configuration.Yaml 3.1.0, Tomlyn 2.11.1), Python 3.14 (tomli 2.5.0, python-dotenv 1.2.4), Node.js 26.13 (dotenv 18.0.10), git and Docker Compose 5.1. The specification used is TOML 1.1.0 (December 2025).
Guess First
When we run the API of Bookstore, the online bookstore used as the shared example across this site's series, locally, we provide some settings from a .env file:
# Bookstore.Api local settings
SHIPPING_DEFAULT_COUNTRY=NO
SHIPPING_NOTE=Free shipping # over 500 TRY
SHIPPING_TRACKING_URL=https://bookstore.example/track#latest
SHIPPING_DOUBLE="first line\nsecond line"
SHIPPING_SINGLE='first line\nsecond line'
SHIPPING_MULTILINE="line one
line two"
SHIPPING_SPACES = padded
export SHIPPING_EXPORTED=yes
SHIPPING_TEMPLATE=${SHIPPING_DEFAULT_COUNTRY}-express
SHIPPING_EMPTY=
This file is read by Docker Compose (env_file), Node.js's --env-file option, the dotenv package from npm, python-dotenv in Python and bash's source command. Do the #latest in SHIPPING_TRACKING_URL and the ${...} in SHIPPING_TEMPLATE come out the same in every tool? Which one tries to run a line of the file as a command?
TOML 1.1: A Typed, Explicit Config Format
TOML appeared as an answer to JSON's lack of comments and YAML's type guessing. Its syntax looks like INI: [Shipping] opens a table (an object in JSON) and the key = value lines below it go into that table. The difference is that types are explicit: strings are always quoted, there are no booleans other than true/false, and leading zeros are forbidden in numbers. Its most important departure from JSON and YAML is that date and time types are part of the spec: offset date-time, local date-time, local date and local time. Bookstore.Api's shipping settings in TOML:
# Bookstore.Api shipping settings (TOML 1.1)
[Shipping]
DefaultCountry = "NO"
Cutoff = 12:30
Express = true
MaxWeightGrams = 1_000
ApiVersion = "1.10"
Carriers = ["standard", "express"]
# array of tables: each [[...]] header adds one element
[[Shipping.Rates]]
Region = "domestic"
Price = 49.90
[[Shipping.Rates]]
Region = "international"
Price = 349.90
[[Shipping.Rates]] is an array of tables: each header adds a new table to the Rates array. It corresponds to an array of objects in JSON. The underscore in 1_000 is for readability; the value is 1000. TOML 1.1.0, published in December 2025, added four things: inline tables that can span several lines with a trailing comma, the \e and \xHH escapes in strings, and optional seconds in times. The last one matters for this file, because Cutoff = 12:30 is invalid in 1.0:
#:package Tomlyn@2.11.1
#:property PublishAot=false
using Tomlyn;
string text = File.ReadAllText("settings/shipping.toml");
Settings settings = TomlSerializer.Deserialize<Settings>(text)!;
ShippingSettings shipping = settings.Shipping;
// TOML has real date/time types: Cutoff arrives as TimeOnly, not as a string
Console.WriteLine($"Cutoff: {shipping.Cutoff:HH:mm} ({shipping.Cutoff.GetType().Name})");
Console.WriteLine($"MaxWeightGrams: {shipping.MaxWeightGrams}, ApiVersion: {shipping.ApiVersion}");
foreach (Rate rate in shipping.Rates)
{
Console.WriteLine($"Rate: {rate.Region} {rate.Price}");
}
class Settings
{
public ShippingSettings Shipping { get; set; } = new();
}
class ShippingSettings
{
public string DefaultCountry { get; set; } = "";
public TimeOnly Cutoff { get; set; }
public bool Express { get; set; }
public int MaxWeightGrams { get; set; }
public string ApiVersion { get; set; } = "";
public List<string> Carriers { get; set; } = [];
public List<Rate> Rates { get; set; } = [];
}
class Rate
{
public string Region { get; set; } = "";
public decimal Price { get; set; }
}
$ python read_toml.py settings/shipping.toml
tomllib TOMLDecodeError Expected newline or end of document after a statement (at line 4, column 12)
tomli 12:30:00 1000 {'Region': 'international', 'Price': 349.9}
$ dotnet run toml.cs
Cutoff: 12:30 (TimeOnly)
MaxWeightGrams: 1000, ApiVersion: 1.10
Rate: domestic 49.9
Rate: international 349.9
tomllib from Python's standard library (3.14) implements TOML 1.0 and stopped at 12:30; tomli 2.4 and later support 1.1 and read the value as a time. In .NET, Tomlyn 2.11 supports 1.1 as well and bound Cutoff straight to a TimeOnly; in YAML and JSON this value was always a string. One caveat: TOML has no decimal type, fractional numbers are defined as IEEE 754 doubles. So 49.90 arrived in a decimal property as 49.9; keeping money in TOML as a string or as an integer number of minor units is safer.
You meet TOML most in Rust's Cargo.toml, in Python projects' pyproject.toml, in mise.toml of mise, which manages tool versions, and in the settings file of the Starship prompt. In .NET it is not common: Microsoft has no configuration provider for TOML. You can read the file yourself with a library such as Tomlyn.
The INI Family: A Standard Without a Standard
INI is the oldest and simplest format. .editorconfig, .gitconfig, php.ini and systemd unit files belong to this family. The problem is that INI has no specification: every parser interprets comments, quotes and letter case its own way. Let's read the same file with three parsers:
# the same file, read by three INI parsers
[Shipping]
Note="Free shipping"
Code=TR ; is this a comment?
TrackingUrl=https://bookstore.example/track#latest
$ dotnet run ini.cs -- settings/quirks.ini
Shipping:Code = [TR ; is this a comment?]
Shipping:Note = [Free shipping]
Shipping:TrackingUrl = [https://bookstore.example/track#latest]
$ python -c 'import configparser; c = configparser.ConfigParser(); c.read("settings/quirks.ini"); print(dict(c["Shipping"]))'
{'note': '"Free shipping"', 'code': 'TR ; is this a comment?', 'trackingurl': 'https://bookstore.example/track#latest'}
$ git config --file settings/quirks.ini --list
shipping.note=Free shipping
shipping.code=TR
shipping.trackingurl=https://bookstore.example/track
Three parsers, three results. .NET's INI provider strips double quotes and treats an inline ; or # as part of the value. Python's configparser keeps the quotes and lowercases the key names. git treats an inline ; or # as the start of a comment; the #latest part of the tracking URL is gone. The rule: don't write comments at the end of a line in an INI file, put them on their own line, and if a value contains ; or #, check the documentation of the tool that will read the file. The rules of .editorconfig itself are in the Coding Conventions and Team Standards series.
.env: The Format Everyone Uses and Nobody Defined
The .env file has no specification either; every library applies its own "shell-like" rules. The results for the file from the guess (the Node script uses the dotenv package; run npm install in the folder first):
$ cat compose.yaml
name: bookstore
services:
api:
image: bookstore-api:dev
env_file: sample.env
$ docker compose config | yq -o json -I0 '.services.api.environment | pick(["SHIPPING_NOTE", "SHIPPING_TRACKING_URL", "SHIPPING_TEMPLATE", "SHIPPING_DOUBLE", "SHIPPING_SINGLE", "SHIPPING_SPACES", "SHIPPING_EXPORTED"])'
{"SHIPPING_NOTE":"Free shipping","SHIPPING_TRACKING_URL":"https://bookstore.example/track#latest","SHIPPING_TEMPLATE":"NO-express","SHIPPING_DOUBLE":"first line\nsecond line","SHIPPING_SINGLE":"first line\\nsecond line","SHIPPING_SPACES":"padded","SHIPPING_EXPORTED":"yes"}
$ node --env-file=sample.env show-env.mjs builtin
{"SHIPPING_NOTE":"Free shipping","SHIPPING_TRACKING_URL":"https://bookstore.example/track","SHIPPING_TEMPLATE":"${SHIPPING_DEFAULT_COUNTRY}-express","SHIPPING_DOUBLE":"first line\nsecond line","SHIPPING_SINGLE":"first line\\nsecond line","SHIPPING_SPACES":"padded","SHIPPING_EXPORTED":"yes"}
$ node show-env.mjs dotenv
{"SHIPPING_NOTE":"Free shipping","SHIPPING_TRACKING_URL":"https://bookstore.example/track","SHIPPING_TEMPLATE":"${SHIPPING_DEFAULT_COUNTRY}-express","SHIPPING_DOUBLE":"first line\nsecond line","SHIPPING_SINGLE":"first line\\nsecond line","SHIPPING_SPACES":"padded","SHIPPING_EXPORTED":"yes"}
$ python show_env.py
{"SHIPPING_NOTE": "Free shipping", "SHIPPING_TRACKING_URL": "https://bookstore.example/track#latest", "SHIPPING_TEMPLATE": "NO-express", "SHIPPING_DOUBLE": "first line\nsecond line", "SHIPPING_SINGLE": "first line\\nsecond line", "SHIPPING_SPACES": "padded", "SHIPPING_EXPORTED": "yes"}
$ bash -c 'set -a; source sample.env; set +a; echo "$SHIPPING_NOTE|$SHIPPING_TRACKING_URL"'
sample.env: line 3: shipping: command not found
sample.env: line 9: SHIPPING_SPACES: command not found
|https://bookstore.example/track#latest
The answer to the guess lies in two differences. The first is #: Compose and python-dotenv treated #latest in an unquoted value as part of the value, while Node.js and dotenv from npm cut everything after # as a comment. The tracking link that works in Compose silently got shorter in a Node.js script using the same .env file. The second is ${...}: Compose and python-dotenv expanded the variable, Node.js and dotenv left it as it is. Bash, on the other hand, ran the file as a shell script: it read the line SHIPPING_NOTE=Free shipping as "run the shipping command with SHIPPING_NOTE=Free in its environment", and it took the line with spaces around the equals sign for a command too. So source .env is only safe if the file follows bash syntax exactly.
There is common ground too: all four left \n inside single quotes as it is, turned the one inside double quotes into a line break, and tolerated spaces around the equals sign and the export prefix. The rule set for a portable .env: use single quotes if the value contains spaces, #, $ or a backslash; don't write end-of-line comments; don't rely on variable expansion; don't put spaces around the equals sign. We covered the difference between interpolation with .env and env_file in Compose in the configuration and secrets post of the Docker Compose series; here we only look at the format.
One Setting, Five Formats, One Configuration
.NET's configuration system is format-independent: each provider reads its own format and turns it into flat keys such as Shipping:DefaultCountry, and the application binds these keys to an options class. We wrote Bookstore.Api's shipping settings as JSON, YAML, INI and environment variables (we saw the TOML version above):
$ cat settings/shipping.json settings/shipping.yaml
{
"Shipping": {
"DefaultCountry": "NO",
"Cutoff": "12:30",
"Express": true,
"MaxWeightGrams": 1000,
"ApiVersion": "1.10",
"Carriers": ["standard", "express"]
}
}
# Bookstore.Api shipping settings (YAML)
Shipping:
DefaultCountry: NO
Cutoff: 12:30
Express: yes
MaxWeightGrams: 1000
ApiVersion: 1.10
Carriers:
- standard
- express
$ cat settings/shipping.ini settings/shipping.env
# Bookstore.Api shipping settings (INI)
[Shipping]
DefaultCountry=NO
Cutoff=12:30
Express=true
MaxWeightGrams=1000
ApiVersion=1.10
Carriers:0=standard
Carriers:1=express
# Bookstore.Api shipping settings (environment variables)
Shipping__DefaultCountry=NO
Shipping__Cutoff=12:30
Shipping__Express=true
Shipping__MaxWeightGrams=1000
Shipping__ApiVersion=1.10
Shipping__Carriers__0=standard
Shipping__Carriers__1=express
Each format builds the hierarchy its own way: nesting in JSON and YAML, the [Shipping] section and : in INI, a double underscore (__) in environment variables, because some platforms don't allow : in variable names. Arrays are written with an index in INI and environment variables (Carriers:0, Shipping__Carriers__0). The program that reads the files:
#:package Microsoft.Extensions.Configuration.Json@10.0.15
#:package Microsoft.Extensions.Configuration.Ini@10.0.15
#:package Microsoft.Extensions.Configuration.EnvironmentVariables@10.0.15
#:package Microsoft.Extensions.Configuration.Binder@10.0.15
#:package NetEscapades.Configuration.Yaml@3.1.0
#:package YamlDotNet@18.1.0
#:property PublishAot=false
using Microsoft.Extensions.Configuration;
string format = args[0];
ConfigurationBuilder builder = new();
builder.SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "settings"));
// one provider per format; the keys they produce are the same
_ = format switch
{
"json" => builder.AddJsonFile("shipping.json"),
"ini" => builder.AddIniFile("shipping.ini"),
"yaml" => builder.AddYamlFile("shipping.yaml"),
"env" => builder.AddEnvironmentVariables(),
_ => throw new ArgumentException($"unknown format {format}")
};
IConfigurationRoot config = builder.Build();
foreach ((string key, string? value) in config.GetSection("Shipping").AsEnumerable(makePathsRelative: false).OrderBy(p => p.Key))
{
if (value is not null)
{
Console.WriteLine($"{key} = {value}");
}
}
try
{
ShippingOptions options = config.GetSection("Shipping").Get<ShippingOptions>()!;
Console.WriteLine($"Bound: {options.DefaultCountry}, {options.Cutoff:HH:mm}, Express={options.Express}, {options.ApiVersion}, [{string.Join(", ", options.Carriers)}]");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Binding failed: {ex.Message}");
}
class ShippingOptions
{
public string DefaultCountry { get; set; } = "";
public TimeOnly Cutoff { get; set; }
public bool Express { get; set; }
public int MaxWeightGrams { get; set; }
public string ApiVersion { get; set; } = "";
public List<string> Carriers { get; set; } = [];
}
$ dotnet run config.cs -- json
Shipping:ApiVersion = 1.10
Shipping:Carriers:0 = standard
Shipping:Carriers:1 = express
Shipping:Cutoff = 12:30
Shipping:DefaultCountry = NO
Shipping:Express = True
Shipping:MaxWeightGrams = 1000
Bound: NO, 12:30, Express=True, 1.10, [standard, express]
$ dotnet run config.cs -- ini
Shipping:ApiVersion = 1.10
Shipping:Carriers:0 = standard
Shipping:Carriers:1 = express
Shipping:Cutoff = 12:30
Shipping:DefaultCountry = NO
Shipping:Express = true
Shipping:MaxWeightGrams = 1000
Bound: NO, 12:30, Express=True, 1.10, [standard, express]
$ dotnet run config.cs -- yaml
Shipping:ApiVersion = 1.10
Shipping:Carriers:0 = standard
Shipping:Carriers:1 = express
Shipping:Cutoff = 12:30
Shipping:DefaultCountry = NO
Shipping:Express = yes
Shipping:MaxWeightGrams = 1000
Binding failed: Failed to convert configuration value 'yes' at 'Shipping:Express' to type 'System.Boolean'.
$ (set -a; source settings/shipping.env; set +a; dotnet run config.cs -- env)
Shipping:ApiVersion = 1.10
Shipping:Carriers:0 = standard
Shipping:Carriers:1 = express
Shipping:Cutoff = 12:30
Shipping:DefaultCountry = NO
Shipping:Express = true
Shipping:MaxWeightGrams = 1000
Bound: NO, 12:30, Express=True, 1.10, [standard, express]
All four formats produced the same keys; that is why layering works: an environment variable can override a value from appsettings.json. The details are interesting. The JSON provider stored JSON's true as the string True; in configuration every value is a string. The YAML provider left the values unparsed, that is, as NO, 12:30 and 1.10. None of the type-guessing problems from the previous posts appear here, because the options class decides the type. But it could not turn Express: yes into a bool; .NET does not consider yes a boolean. If you read YAML in .NET, write values in a form .NET understands. Provider order, secrets and ValidateOnStart are in the configuration post of the ASP.NET Core Web API series and the layering post of the Configuration and Secret Management series.
Windows, Linux and Letter Case
On Windows, environment variable names are case-insensitive: Shipping__DefaultCountry and SHIPPING__DEFAULTCOUNTRY are the same variable. On Linux and macOS they are two different variables. Because .NET configuration keys are case-insensitive on every platform, if both are defined in a Linux container they land on the same key:
$ Shipping__DefaultCountry=NO SHIPPING__DEFAULTCOUNTRY=SE dotnet run config.cs -- env | grep -i -E "defaultcountry|bound"
Shipping:DefaultCountry = SE
Bound: SE, 00:00, Express=False, , []
Which one wins is not defined; in this run SE won, with no warning at all. This situation, which you never see while developing on Windows, shows up on Linux when two separate sources add the variables with different spellings (a Dockerfile ENV and a Kubernetes manifest, for example). Pick one spelling as a team (in Bookstore, the same as .NET's property names: Shipping__DefaultCountry) and check for repeated names in CI.
Which Format When?
| Format | Comments | Types | Schema and tool support | Typical human error | Best fit |
|---|---|---|---|---|---|
| JSON | None (JSONC has them) | Explicit, no dates | JSON Schema, a parser in every language | Commas, quotes | APIs, machine-generated config, appsettings.json |
| YAML | # | Guessed, version-dependent | JSON Schema (via editor extensions), linter | Indentation, unquoted values | Kubernetes, Compose, CI pipelines |
| TOML | # | Explicit, including date and time | Not as widespread as JSON and YAML | Table headers in long nested structures | Tool and package settings (pyproject.toml) |
| INI | ;, # (depends on the parser) | All strings | None | End-of-line comments, quotes | Simple, flat settings (.editorconfig) |
| .env | # (depends on the parser) | All strings | None | #, $, spaces | Local development, Compose interpolation |
Our choice in Bookstore: application settings in appsettings.json (JSONC), whatever varies by environment in environment variables, infrastructure in YAML (Kubernetes, Compose, GitHub Actions), and a .env for Compose in local development. We don't pick TOML or INI ourselves, but we need to be able to read them in tools' files (.editorconfig, pyproject.toml). Secrets go into none of these formats: neither into appsettings.json nor into a committed .env. In development you use user-secrets, in production a secret store; the details are in the Configuration and Secret Management series.
In Production
When picking a config format, the real question is "who will read the file?". If there is a single reader (only .NET, say), its rules are enough. If several tools read the same file (a .env read by both Compose and a Node.js script), write it in the strictest common subset and test it with both in CI. The # difference in this post shortened a tracking link; in a connection string the same difference can shorten a password.
Collecting environment variables from a single source (a Kubernetes manifest, an App Service setting, a Compose file) and keeping them in one place prevents repeated names. Validating the options bound at startup (ValidateOnStart) also makes a badly formatted value show up at deploy time instead of on the first request.
Common Pitfalls
Sourcing a .env with bash when the file contains a line bash does not understand; writing end-of-line comments in a .env; appending ; comment to a value in INI. Forgetting that Python's configparser lowercases keys is common too: c["Shipping"]["TrackingUrl"] works because the lookup is case-insensitive, but if you turn the section into a dict and look for the TrackingUrl key, you won't find it.
The most common TOML mistake is forgetting the scope of table headers: every key you write after a [Shipping] header goes into that table until the next header. If you accidentally write a key meant for the top of the file below a table, it becomes a field of that table instead of a new setting. Finally, before using TOML 1.1 features (times without seconds, multiline inline tables), make sure the tools that will read the file support 1.1; Python's standard tomllib does not yet in 3.14.
A Small Experiment
★☆☆ Put the tracking URL in sample.env in single quotes and run the four tools again. Is #latest kept everywhere now?
★★☆ Change Express: yes to Express: true in settings/shipping.yaml and run config.cs with YAML again. Then add the environment variable provider after the YAML provider and override the YAML value with Shipping__Express=false.
★★★ Add a TOML source to config.cs with Tomlyn: write a small IConfigurationProvider that reads the TOML file and turns it into flat keys such as Shipping:Rates:0:Region, and show that it produces the same output as the other four formats.
In this post we saw how differently the formats can be read. In the next post we look at how to catch these differences while writing: JSON Schema for completion and error highlighting in the editor, SchemaStore, our own schema for Bookstore.Api and validation in CI.