Runtime

Storing CLI App Data with platformdirs

Put a Python CLI’s config, cache, state and logs in the right per-user directories on Linux, macOS and Windows with platformdirs, overrides and a paths command.

Updated

Every CLI that survives a few releases accumulates files of its own: a config file the user edits, a cache of downloaded metadata, a record of the last run, a log for debugging. Where those files go is one of the first things users notice. Drop a .mytool directory into the home directory root and Linux users sigh; write a cache into the working directory and it ends up committed to someone's repository; hard-code ~/.config and Windows users get a folder their system does not recognise. This guide uses the platformdirs package to put each kind of file in the directory each operating system designates for it, adds environment and flag overrides, and gives users a command to find and clean it all. It belongs to the filesystem topic.

Prerequisites

  • Python 3.10+ and platformdirs 4.x (uv add platformdirs).
  • A Typer or Click CLI.
  • A clear idea of which files your tool owns. If you are unsure, the decision section below will help.

Four kinds of file, four directories

Operating systems distinguish between files by what losing them would cost. The XDG base-directory specification on Linux, Apple's guidelines on macOS and Microsoft's known-folders on Windows all draw roughly the same lines, and platformdirs maps a single API onto each:

The same directory on three systems Where platformdirs places the user config, cache and state directories for an application on Linux, macOS and Windows. The same directory on three systems Directory Linux macOS Windows Config ~/.config/mytool ~/Library/Application Support/mytool %LOCALAPPDATA%\mytool Cache ~/.cache/mytool ~/Library/Caches/mytool %LOCALAPPDATA%\mytool\Cache State ~/.local/state/mytool ~/Library/Application Support/mytool %LOCALAPPDATA%\mytool Logs ~/.local/state/mytool/log ~/Library/Logs/mytool %LOCALAPPDATA%\mytool\Logs On Linux each can be moved with XDG_* variables, which platformdirs respects.
  • Config — settings the user chooses and may edit by hand. Users back this up and sync it between machines.
  • Cache — anything your tool can rebuild: downloaded indexes, compiled templates, API responses. Deleting it must be harmless, and system cleanup tools may do exactly that.
  • State — data the tool itself maintains that is not worth backing up but is annoying to lose: command history, "last checked for updates" timestamps, a record of which migrations ran. (On macOS and Windows this lands next to the data directory; on Linux it is the separate ~/.local/state.)
  • Logs — diagnostic output kept between runs.

There is also a data directory, for files the user would lose work without but does not edit directly — a local database or downloaded plugins. Many CLIs never need it.

Which directory does this file go in? A decision for placing an application file: if losing it is harmless it belongs in the cache, otherwise in the config or state directory depending on whether the user edits it. Which directory does this file go in? Would deleting it lose anything? No — it can be rebuilt Cache downloads, indexes Yes, and the user edits it Config settings files Yes, but the tool owns it State history, last run Getting this right is what lets users back up config and wipe caches without fear.

The recipe

Put the directory logic in one module. Everything else in the CLI imports paths from it and never computes a location itself.

# src/mytool/paths.py
from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path

from platformdirs import PlatformDirs

APP = "mytool"


@dataclass(frozen=True)
class AppPaths:
    config_dir: Path
    cache_dir: Path
    state_dir: Path
    log_dir: Path

    @property
    def config_file(self) -> Path:
        return self.config_dir / "config.toml"

    def ensure(self) -> None:
        for d in (self.config_dir, self.cache_dir, self.state_dir, self.log_dir):
            d.mkdir(parents=True, exist_ok=True)


def _override(var: str) -> Path | None:
    value = os.environ.get(var)
    return Path(value).expanduser() if value else None


def resolve_paths(config_dir: Path | None = None) -> AppPaths:
    """Directories for this run: flag > MYTOOL_* env var > platform default."""
    dirs = PlatformDirs(APP, appauthor=False)
    return AppPaths(
        config_dir=config_dir or _override("MYTOOL_CONFIG_DIR") or dirs.user_config_path,
        cache_dir=_override("MYTOOL_CACHE_DIR") or dirs.user_cache_path,
        state_dir=_override("MYTOOL_STATE_DIR") or dirs.user_state_path,
        log_dir=_override("MYTOOL_LOG_DIR") or dirs.user_log_path,
    )

A few deliberate choices:

  • appauthor=False. On Windows, platformdirs otherwise nests directories under an author name (%LOCALAPPDATA%\Acme\mytool). For a developer tool with no company brand, a flat directory is what users expect.
  • Nothing is created at import time. ensure() runs only when a command is about to write. A --help or a read-only command should never create empty directories. (PlatformDirs(..., ensure_exists=True) exists, but it creates directories whenever a path property is accessed, which is too eager for a CLI.)
  • Overrides are layered. A --config-dir flag beats a MYTOOL_CONFIG_DIR environment variable, which beats the platform default — the same precedence the site recommends for every setting in config precedence: flags, env, files and defaults. On Linux, platformdirs already honours XDG_CONFIG_HOME and friends, so your variables are an extra, tool-specific layer on top.

Now wire it into the CLI. The callback resolves paths once and stores them on the context so every command sees the same values:

# src/mytool/cli.py
import shutil
from pathlib import Path

import typer

from mytool.paths import AppPaths, resolve_paths

app = typer.Typer()
cache_app = typer.Typer(help="Manage the local cache.")
app.add_typer(cache_app, name="cache")


def _size(path: Path) -> int:
    return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) if path.exists() else 0


@app.callback()
def main(
    ctx: typer.Context,
    config_dir: Path = typer.Option(None, "--config-dir", envvar="MYTOOL_CONFIG_DIR",
                                    help="Read and write configuration here."),
) -> None:
    """mytool — an example of well-placed files."""
    ctx.obj = resolve_paths(config_dir)


@app.command()
def paths(ctx: typer.Context) -> None:
    """Show where mytool keeps its files."""
    p: AppPaths = ctx.obj
    rows = [("config", p.config_dir), ("cache", p.cache_dir),
            ("state", p.state_dir), ("logs", p.log_dir)]
    for label, d in rows:
        extra = f"  ({_size(d) / 1e6:.1f} MB)" if label == "cache" and d.exists() else ""
        typer.echo(f"{label:<7} {d}{extra}")


@cache_app.command("clear")
def cache_clear(ctx: typer.Context) -> None:
    """Delete everything in the cache directory."""
    p: AppPaths = ctx.obj
    if not p.cache_dir.exists():
        typer.echo("cache is already empty", err=True)
        return
    count = sum(1 for f in p.cache_dir.rglob("*") if f.is_file())
    shutil.rmtree(p.cache_dir)
    typer.echo(f"removed {count} files", err=True)


if __name__ == "__main__":
    app()

Sharing resolved paths through ctx.obj is the standard pattern from sharing state with Click context objects; it keeps commands free of path logic and makes them trivial to test.

UX considerations

Let users find your files Terminal output of a paths command that prints where the tool keeps its config, cache and state directories. Let users find your files bash $ mytool paths config /home/ana/.config/mytool cache /home/ana/.cache/mytool (38.2 MB) state /home/ana/.local/state/mytool $ mytool cache clear removed 214 files (38.2 MB) A paths command answers the support question "where does it keep its stuff?" before anyone asks.
  • Ship a paths command. "Where does it keep its config?" is the most common support question for any tool with a config file. One command answers it on every platform, and it doubles as a debugging aid when overrides are in play.
  • Make the cache disposable, and say so. A cache clear command, plus a sentence in the help text that the cache can be deleted at any time, gives users confidence to reclaim space.
  • Never write to the working directory implicitly. Output the user asked for (-o report.csv) goes where they said. Your tool's own files never go in the project they happen to be standing in.
  • Honour a project-level config too, if it helps. Tools like linters benefit from a config file in the repository as well as a user one. That is a separate discovery mechanism — see discovering project config files by walking up directories.
  • Migrate old locations gently. If earlier versions used ~/.mytool, read from it when the new location is empty, print a one-line notice, and move the files once. Silently ignoring the old directory looks like data loss.

Testing the behaviour

Tests must never touch the real home directory. Point every override at tmp_path in a fixture and use the CLI as normal:

# tests/test_paths.py
import pytest
from typer.testing import CliRunner

from mytool.cli import app
from mytool.paths import resolve_paths

runner = CliRunner()


@pytest.fixture
def isolated(tmp_path, monkeypatch):
    for kind in ("CONFIG", "CACHE", "STATE", "LOG"):
        monkeypatch.setenv(f"MYTOOL_{kind}_DIR", str(tmp_path / kind.lower()))
    return tmp_path


def test_env_overrides(isolated):
    p = resolve_paths()
    assert p.config_file == isolated / "config" / "config.toml"
    assert p.cache_dir == isolated / "cache"


def test_flag_beats_env(isolated, tmp_path):
    p = resolve_paths(config_dir=tmp_path / "flag")
    assert p.config_dir == tmp_path / "flag"


def test_paths_command_creates_nothing(isolated):
    result = runner.invoke(app, ["paths"])
    assert result.exit_code == 0
    assert "config" in result.output
    assert not (isolated / "cache").exists()


def test_cache_clear(isolated):
    cache = isolated / "cache"
    (cache / "sub").mkdir(parents=True)
    (cache / "sub" / "a.json").write_text("{}")
    result = runner.invoke(app, ["cache", "clear"])
    assert result.exit_code == 0
    assert not cache.exists()

The "creates nothing" test is worth keeping: it catches the regression where someone adds mkdir to an import-time path computation and every --help starts littering the home directory.

Conclusion

Put each file where the operating system expects that kind of file — config, cache, state, logs — using platformdirs so one line of code is right on Linux, macOS and Windows. Resolve directories in one module, layer flag and environment overrides on top, create directories only when writing, and give users a paths command and a way to clear the cache. Combined with atomic writes for the files that matter, your tool's footprint on a user's machine becomes predictable and easy to manage.

Frequently asked questions

Isn't ~/.mytoolrc simpler?

It is simpler for you and worse for users. A home directory full of dot-files is hard to manage, and nothing distinguishes config worth backing up from cache worth deleting. If you already ship a dot-file, keep reading it as a legacy location while writing to the platform directory.

Should macOS use ~/.config instead of Application Support?

Many developer tools do, because their users expect Linux-style paths and share dotfiles across systems. platformdirs follows Apple's convention. If your audience strongly prefers ~/.config, honour XDG_CONFIG_HOME explicitly on macOS as an opt-in, and document it in your paths output.

Where should credentials go?

Not in any of these directories as plain text if you can avoid it. Use the system keychain via keyring, and keep only non-secret metadata (which account is active) in state. See storing tokens with keyring.

How should the tool behave in CI or a read-only container?

Assume the home directory may be missing, read-only or thrown away after every job. Treat a failure to create the cache directory as a warning, not an error — run without a cache and say so once on stderr. Let operators redirect every directory with the MYTOOL_*_DIR variables so a CI job can point the cache at a directory its runner persists between builds, which is often the single biggest speed-up available for tools that download metadata. And never require a config file to exist: defaults plus flags and environment variables should be enough to run anywhere.

What about per-project caches, like .pytest_cache?

A cache tied to one project and invalidated with it can reasonably live in that project, as pytest and mypy do — but create it only when the user runs your tool there, add a .gitignore inside it containing *, and document it.