---
title: Dotenv Support
description: Parse and format .env files with parseDotenv and formatDotenv from @envlock/core. Full support for quoting, escaping, multiline values, comments, and round-trip fidelity.
url: https://pr-1-ff84656b4b8a.thally.app/envlock/dotenv
---

# Dotenv Support

Parse and format .env files with parseDotenv and formatDotenv from @envlock/core. Full support for quoting, escaping, multiline values, comments, and round-trip fidelity.

The `@envlock/core` package includes a dotenv parser and formatter that handle
the full range of `.env` file conventions: quoted values, backslash escapes,
multiline strings, inline comments, and the `export` prefix. Both functions are
pure and never throw.

## parseDotenv

```ts
function parseDotenv(text: string): Record<string, string>
```

Parses the text content of a `.env` file into a plain key-value record. The
parser is intentionally lenient: malformed lines are silently skipped, and the
function never throws.

### Supported syntax

- **Assignments**: `KEY=value` pairs, one per line.
- **Export prefix**: `export KEY=value` is accepted (the `export` keyword is
  stripped).
- **Comments**: lines starting with `#` are skipped.
- **Empty values**: `KEY=` produces an empty string for that key.
- **Single-quoted values**: treated as literal text with no escape processing.
- **Double-quoted values**: backslash escapes are processed for `\n`, `\r`,
  `\t`, `\"`, `\\`, and `\$`.
- **Multiline quoted values**: both single- and double-quoted values may span
  multiple lines.
- **Inline comments**: a `#` preceded by whitespace after an unquoted value
  starts a comment. Embedded hashes without preceding whitespace are kept:
  `a#b` stays `a#b`.
- **CRLF normalization**: `\r\n` is normalized to `\n` before parsing.
- **Duplicate keys**: later assignments override earlier ones.
- **Valid key names**: keys must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. Lines with
  keys that do not match this pattern are skipped.
- **Malformed lines**: lines without an `=` sign or with an invalid key are
  silently skipped.

### Example

```ts
import { parseDotenv } from "@envlock/core";

const text = `
# Database settings
DATABASE_URL=postgres://localhost/mydb
export PORT=3000

# App config
APP_NAME="My App"
GREETING='Hello, world!'
SECRET_KEY="line1\\nline2"
EMPTY_VAR=
TAG=a#b
LABEL=hello # this is a comment
`;

const record = parseDotenv(text);

console.log(record.DATABASE_URL); // "postgres://localhost/mydb"
console.log(record.PORT);         // "3000"
console.log(record.APP_NAME);     // "My App"
console.log(record.GREETING);     // "Hello, world!"
console.log(record.SECRET_KEY);   // "line1\nline2" (actual newline)
console.log(record.EMPTY_VAR);    // ""
console.log(record.TAG);          // "a#b"
console.log(record.LABEL);        // "hello"
```

## formatDotenv

```ts
function formatDotenv(
  record: Readonly<Record<string, string>>,
): string
```

Serializes a key-value record into dotenv format.

- **Key order**: keys appear in the order they exist in the record.
- **Selective quoting**: values are double-quoted only when they contain
  whitespace, `#`, quotes, backslash, `$`, a newline character, or when the
  value is an empty string. Simple values are written unquoted.
- **Empty record**: returns an empty string (no output).
- **Trailing newline**: a non-empty result always ends with `\n`.

### Example

```ts
import { formatDotenv } from "@envlock/core";

const output = formatDotenv({
  PORT: "3000",
  DATABASE_URL: "postgres://localhost/mydb",
  APP_NAME: "My App",
  EMPTY: "",
});

console.log(output);
// PORT=3000
// DATABASE_URL=postgres://localhost/mydb
// APP_NAME="My App"
// EMPTY=""
```

## Round-trip fidelity

`parseDotenv` and `formatDotenv` are designed to round-trip: parsing the output
of `formatDotenv` produces the same record you started with.

```ts
import { parseDotenv, formatDotenv } from "@envlock/core";

const original = {
  HOST: "0.0.0.0",
  PORT: "8080",
  GREETING: "Hello, world!",
  EMPTY: "",
};

const text = formatDotenv(original);
const parsed = parseDotenv(text);

// parsed is deeply equal to original
console.log(parsed.HOST);     // "0.0.0.0"
console.log(parsed.PORT);     // "8080"
console.log(parsed.GREETING); // "Hello, world!"
console.log(parsed.EMPTY);    // ""
```

## Integrating with loadEnv and parseEnv

`parseDotenv` returns a plain `Record<string, string>` that can be passed
directly to `loadEnv` or `parseEnv` as the `source` parameter. This lets you
validate a `.env` file against a schema without touching `process.env`.

The typical pattern is: read the file, parse it with `parseDotenv`, then pass
the resulting record to `loadEnv` or `parseEnv`.

### Validating a .env file with loadEnv

```ts
import { readFileSync } from "node:fs";
import { parseDotenv, loadEnv } from "@envlock/core";
import schema from "./envlock.config.mjs";

// 1. Read the .env file
const text = readFileSync(".env", "utf-8");

// 2. Parse into a record
const source = parseDotenv(text);

// 3. Validate against the schema (throws on failure)
const config = loadEnv(schema, source);
```

### Non-throwing validation with parseEnv

```ts
import { readFileSync } from "node:fs";
import { parseDotenv, parseEnv } from "@envlock/core";
import schema from "./envlock.config.mjs";

const text = readFileSync(".env", "utf-8");
const source = parseDotenv(text);

const result = parseEnv(schema, source);

if (result.ok) {
  console.log("Valid:", result.values);
} else {
  for (const issue of result.issues) {
    console.error(`${issue.key}: ${issue.message}`);
  }
}
```

### Layering a .env file over process.env

You can merge a parsed `.env` file with `process.env` so file values take
precedence while environment variables fill in the rest.

```ts
import { readFileSync } from "node:fs";
import { parseDotenv, loadEnv } from "@envlock/core";
import schema from "./envlock.config.mjs";

const fileVars = parseDotenv(readFileSync(".env", "utf-8"));

// File values override process.env
const merged = { ...process.env, ...fileVars };

const config = loadEnv(schema, merged);
```