Skip to main content

uv-stack

Reusable package sets for shared Python environments and uv projects — powered by micromamba and uv.

You define what you want in a few small files — profiles (named package groups) and bundles (groups of profiles). Then you install those definitions somewhere: a shared environment you activate (one stack.txt per environment), or a uv project that carries its own .venv. The stack command renders, locks, and installs everything else, splitting the work between two tools:

Tool Owns
micromamba The Python interpreter and conda-level binary packages (from conda-forge)
uv Every pip-level package — compiled to a lock file, then synced exactly

micromamba builds the room (the interpreter and any conda-level binaries); uv furnishes it (fast, fully locked pip packages). The same profiles and bundles feed both destinations. The box on the left is what you author — inside [tool.uv-stack] that means the stack key, since the rest of that table is uv-stack's own bookkeeping. requirements.in and environment.yml are generated and carry a # Generated by uv-stack — do not edit. header, and uv compiles requirements.lock.txt from requirements.in. On the project path stack writes the resolved packages into your own pyproject.toml and records what it applied back into [tool.uv-stack], leaving the lock and .venv to uv.

Why you might want this:

  • Define packages once, reuse everywhere. The same profile feeds your shared notebook environment and your uv projects.
  • Shared environments are rebuildable. A shared environment is a pure function of its source files. Delete it, stack create env NAME, and it comes back.
  • Real locks. Every shared environment gets a fully pinned requirements.lock.txt, compiled and installed with uv (fast), and verified with uv pip check.

Installation

Prerequisites (both must be installed and reachable):

  • uv
  • micromamba — run micromamba shell init after installing so $MAMBA_EXE is set

Then install the CLI as a standalone tool (requires Python 3.12+):

uv tool install uv-stack

(pip install uv-stack works too.) This gives you one command: stack.

Quickstart

Five minutes from nothing to a working, locked environment.

# One guided command sets up everything with prompts — a config tree, a
# commented 'starter' profile, and a 'main' environment (--yes accepts defaults):
stack init

# Prefer explicit steps? An equivalent setup by hand, with your own profile:
stack config init
stack create profile ds numpy pandas --description "Core data-science stack" --tag data
stack create env main ds --python 3.12

# Use it (stack prints this hint after every successful create env):
micromamba activate main
# ...or run things in it without activating:
micromamba run -n main python

What just happened: stack resolved the ds token to your profile, generated requirements.in and environment.yml inside envs/main/, had micromamba create the main environment (Python 3.12 by default), compiled a pinned requirements.lock.txt with uv pip compile, installed it exactly with uv pip sync, and verified consistency with uv pip check.

From now on, changing what's installed is the same two steps: edit a source file (a profile, a bundle, or stack.txt), then run stack upgrade main. For a freshly cloned config root, use stack sync to build the environments first. Check what needs rebuilding at any time with stack status.

What you define

Profiles

A profile is a named list of pip packages, stored as profiles/<name>.yaml in the config root:

description: Core data-science stack   # optional
tags: [data, core]                     # optional, used by `stack list --tag`
includes:                              # required: literal pip requirements
  - numpy>=2
  - pandas

includes entries may reference a variable the config root declares, written ${NAME}. This is what lets one set of profiles describe editable checkouts that live in different places on different machines:

includes:
  - numpy>=2
  - -e ${DEV}/mytool

Three rules govern where a reference may appear:

  1. Every entry is a single line. One requirement per line, always.
  2. The name must be declared in variables.txt. A name the environment happens to export but the root does not declare is an error, because the declaration is what makes the reference part of the root's portable contract.
  3. A reference may appear only in an editable (-e ${DEV}/pkg), in another option line (--find-links ${WHEELS}), or in an entry whose first token is a path (${DEV}/pkg) — and never in a -r/--requirement or -c/--constraint include.

Rule 3 is not stylistic. A reference in the distribution-name position (${PKG}>=2) would make the same entry mean different packages on different machines, which corrupts the dependency ownership a tracked project records in its pyproject.toml. A reference inside an included file would pull in dependencies the config root cannot see. stack doctor reports every entry that breaks one of these rules, naming the file, the entry, and the rule.

Bundles

A bundle is a recipe that composes profiles, other bundles, and literal packages, stored as bundles/<name>.yaml. Same schema, but includes entries are stack tokens:

description: Everything for daily work
includes:
  - ds          # a profile
  - chem        # another profile
  - utils

Bundles can nest other bundles; duplicates are removed automatically.

Stack tokens

Tokens are how you reference things in stack.txt, bundle includes, and on the stack create project / stack resolve command line:

Token Meaning
ds (bare name) Profile ds if it exists, else bundle ds, else a literal package
profile:ds Profile ds (error if missing)
@standard or bundle:standard Bundle standard (error if missing)
pkg:numpy or package:numpy Always a literal package — the escape hatch when a name collides with a profile or bundle
numpy>=2, -e ~/src/mytool, archive paths Literal pip requirement, passed through

Use stack resolve to see how tokens are classified, and stack resolve --full to expand them into a flat package list.

Where it goes

A definition is inert until you install it somewhere, and there are two destinations. The choice is not "an environment or a project" — either way you end up with an interpreter and a set of installed packages, and a project carries its own .venv for exactly that. The real difference is where the packages live: in one central, named place you activate from anywhere, or inside a single directory alongside the code that uses them.

Shared environment Project
Create with stack create env NAME TOKENS... stack create project TOKENS...
Lives in envs/<name>/ in your config root the current directory — pyproject.toml, uv.lock, .venv/
Enter it with micromamba activate <name> uv run ...
Update with stack upgrade <name> stack refresh
Inspect with stack show env <name>, stack status stack show project
Remove with stack delete env <name> stack delete project
Reach for it when notebooks, ad-hoc work, tooling spanning many repos one codebase, dependencies versioned and shipped with the code

Shared environments are registered centrally under the config root, so stack list env and stack status can enumerate them all. A project is identified by the pyproject.toml in the directory you are standing in: there is no registry to enumerate, and no parent-directory search — run stack refresh and stack show project from the project root. That is also why there is deliberately no stack list project.

Shared environments

A named shared environment lives in envs/<name>/ under the config root. It exists as soon as stack.txt does.

Files you write:

File Purpose
stack.txt Required. One stack token per line; # comments allowed
python.txt Interpreter version (defaults to 3.12); applied at create and --recreate only
micromamba.txt Extra conda packages (e.g. graphviz)
channels.txt Extra conda channels (conda-forge is always first)
requirements.local.in Machine-local pip additions, kept out of profiles

Files stack generates (never edit these):

File Purpose
environment.yml micromamba spec: python=<version>, pip, conda packages
requirements.in Expanded, unpinned pip requirements
requirements.lock.txt Fully pinned lock, produced by uv pip compile

Projects

For per-project work, stack can seed a standard uv project from the same profiles and bundles:

mkdir myproject && cd myproject
stack create project @standard pkg:httpx

This resolves the tokens to a flat package list, then runs uv init --bare, uv add, and uv sync — leaving you a normal uv project with a pyproject.toml, uv.lock, and .venv/.

Choosing the project's interpreter with --python:

  • A version (3.12), a path, or a spec like cpython@3.12 is passed straight to uv.
  • Anything else is treated as a micromamba environment name — the project uses that environment's interpreter. stack create project ds --python main builds the project on main's Python.
  • A botched version — 3.12.*, 3.12.x — is not a version either, so it lands in the case above. It is still refused, but the message names the typo rather than telling you to create an environment called 3.12.x. stack edit project warns about one in the tracking table when the editor exits. (Note that 3.12.* is legal in an environment's python.txt, which conda reads, not uv.)
  • With no --python, the default comes from $UV_STACK_PROJECT_PYTHON, then <config-root>/project-python.txt, then 3.12.

New projects are tracked by default: stack create project records your tokens in a [tool.uv-stack] table inside pyproject.toml (--no-track opts out for one-shot scaffolds). When your profiles or bundles change later, re-resolve the project in place:

stack refresh              # apply profile/bundle changes to this project
stack refresh --dry-run    # see the add/remove delta first

To add tokens as well, use stack sync project TOKENS... (see Upgrading); it extends the recorded stack rather than replacing it.

Refresh removes only packages recorded in the table's applied list — the ones uv-stack itself added. Dependencies whose names uv-stack never applied are never touched. One caveat: ownership is by package name, so if you re-pin a stack-applied package yourself (say uv add 'numpy<2' after a profile applied numpy), uv-stack still owns that name and a later refresh may rewrite or remove it. After an interrupted run, recovery adopts leftover pending names that are still installed but no longer in the stack — it warns first and the next successful stack refresh removes them (the warning tells you how to keep one). Day to day, the project is still a normal uv project — uv add, uv sync, and uv run all work as usual.

Everyday commands

Command What it does
stack init Guided first-run setup (config tree, starter profile, first env)
stack create env NAME [TOKENS]... Scaffold (optional) and build a shared environment (--recreate wipes and rebuilds it: the lock is compiled first, and the conda layer is destroyed only if that succeeds)
stack create profile NAME PKG... Write a new profile YAML (--description, --tag)
stack create bundle NAME TOKEN... Write a new bundle YAML (--description, --tag)
stack delete env NAME Remove a shared environment: its micromamba environment, then envs/NAME/
stack delete profile NAME / stack delete bundle NAME Delete the YAML; refused while something still refers to it (--force overrides)
stack delete project Withdraw uv-stack from the project in this directory: uv remove what it applied, drop [tool.uv-stack]
stack edit KIND [NAME] Open a profile, bundle, env source, project file, or remotes.yaml in your editor and validate it when the editor exits
stack upgrade [NAMES]... Re-render, re-lock, and sync shared environments
stack sync Create, build, and recompile every environment the root declares (no prompt, keeps existing pins)
stack sync env NAME... The same, for only the named environments
stack sync project TOKENS... Add tokens to the tracked project in this directory and re-resolve it (the recorded stack is extended, never replaced)
stack refresh Re-resolve a tracked project against current profiles/bundles
stack status [NAMES]... Shared-env build state: drift, lock freshness, existence
stack diff SOURCE SOURCE Compare two environments — interpreter, micromamba packages, channels, and pins — here or across machines (--json, --exit-code)
stack list env|profile|bundle Tables of what exists (--tag filters, --json for scripts)
stack show env|profile|bundle [NAME] Details for one item (NAME defaults to main for envs)
stack show project The tracked project in this directory: tokens, applied packages, pending state
stack resolve [--full] TOKENS... Classify tokens, or expand them to a flat package list
stack doctor [--fix] Detect problems; --fix applies the safe repairs
stack completion bash|zsh|fish Print the shell-completion script
stack config init Create missing config directories (bare primitive)
stack config portable Write the managed .gitignore block so the config root can be committed
stack config remote list Per-host settings for stack sync remote, with defaults filled in (--json for the stored values)
stack config remote set HOST Set a host's --stack command, --root, or both in remotes.yaml
stack config remote remove HOST [stack|root]... Remove a host's entry, or only the named fields

Editing configuration

stack edit opens a config file in your editor and checks it the moment the editor exits. If the file does not validate, the error is shown and the editor is offered again, with your changes left in place. Nothing is reverted: if you decline the re-offer, the file stays exactly as you left it and the command exits non-zero. When stdin is not a terminal there is no re-offer at all — the error is printed and the command exits 1. Quitting the editor with a non-zero status (:cq in vi) aborts without validating at all.

stack edit profile ds              # profiles/ds.yaml
stack edit bundle standard         # bundles/standard.yaml
stack edit env                     # envs/main/stack.txt
stack edit env chem --file python  # envs/chem/python.txt
stack edit project                 # ./pyproject.toml
stack edit remotes                 # remotes.yaml

On success the command names the file it validated and how to apply the change: stack upgrade NAME for an environment, stack refresh for a tracked project, for a profile or bundle a reminder that it takes effect on the next upgrade or refresh, and for remotes.yaml a reminder that the next stack sync remote uses it. A remotes.yaml that does not exist when the editor exits (you quit without saving, or deleted it) is reported as such and the command exits 0: no file means no remotes.

--file selects which environment source to open and accepts stack (default), python, micromamba, channels, and local (requirements.local.in). It applies to env only. An optional source that does not exist yet is opened anyway, so this is how you add a channels.txt. edit never creates a profile, bundle, or environment — use stack create for that.

Symlinked config files work, so a profile can live in a dotfiles repo and be linked into the config root. When the file you name is itself a symlink, the success line reports the real file it resolves to rather than the link, so you can see where the write landed. A link pointing at nothing is refused instead of followed.

The editor is chosen from the first of these that is set:

  1. --editor
  2. $UV_STACK_EDITOR
  3. <config-root>/editor.txt
  4. $VISUAL
  5. $EDITOR

The value is a command line, so code -w and emacsclient -nw both work; a value that is spelled as a path — absolute, ~-prefixed, or containing a / — and names an existing file is used as a single argument, so a path containing spaces needs no quoting, and a leading ~ is expanded to your home directory. The edited file is appended as the final argument. An empty environment variable or editor.txt is skipped rather than treated as a choice, so an exported-but-empty $VISUAL does not shadow $EDITOR. If none of the five is set, stack edit says so and stops — it will not drop you into an editor you did not choose.

editor.txt uses the same line grammar as the other config-root text files: the first non-blank line wins, and everything from a # onward is a comment. An editor command containing # would therefore be truncated, so set one of the environment variables instead if you need one.

echo 'code -w' > ~/.config/python-envs/editor.txt

Use your editor's blocking flag. stack edit validates the file at the moment the editor process exits. A GUI editor that hands the file to an already-running instance and returns immediately — code without -w, subl without -w, gvim without -f — exits before you have typed anything, so validation runs against the file as it was and reports success prematurely. This cannot be fixed from uv-stack's side; the fix is the flag:

stack edit profile ds --editor 'code -w'

Deleting

stack delete is the inverse of stack create. Every form asks before it acts; -y answers yes, and a declined or unanswered prompt exits 1 with nothing touched.

stack delete profile ds            # profiles/ds.yaml
stack delete bundle standard       # bundles/standard.yaml
stack delete env chem              # the micromamba env, then envs/chem/
stack delete project               # withdraw from ./pyproject.toml

A profile or bundle is refused while an environment's stack.txt or another bundle still refers to it, and the refusal lists each place. The reason is the same one stack import enforces: a bare token that named a profile would silently start meaning the pip package of that name on the next upgrade, and a qualified one (profile:ds, @standard) would stop resolving. Edit the references out first, or pass --force to delete anyway; a forced delete prints one warning per reference it leaves behind. A source the check cannot read counts as a possible reference and is refused the same way.

stack delete env removes only an environment uv-stack manages — one with a stack.txt under the config root. The micromamba environment goes first, so if micromamba remove fails the sources are still there and the command can be run again. If no micromamba environment is built, that step is skipped, and the prompt says so.

stack delete project does not delete your project. It runs uv remove for the packages uv-stack applied — the table's applied list, the same ownership rule stack refresh uses — and drops the [tool.uv-stack] table, leaving a plain uv project with only the dependencies you added yourself. pyproject.toml, uv.lock, and .venv/ stay. The prompt lists what will be removed. A final plain uv sync prunes the removed packages from .venv/ (--no-sync skips it); it is not pinned to the recorded interpreter, since that choice is no longer uv-stack's once the table is gone.

Upgrading

stack upgrade main                      # one environment
stack upgrade main scratch              # several
stack upgrade                           # every environment (lists them, asks first)
stack upgrade --all -y                  # every environment, no prompt
stack upgrade --no-upgrade main         # re-lock without floating pins upward
stack upgrade --upgrade-package numpy main   # upgrade only numpy
stack upgrade --dry-run main            # print the command plan

By default upgrade recompiles the lock with --upgrade (everything floats to the newest allowed versions), then syncs the environment to match the lock exactly — packages you removed from a profile are uninstalled. A batch keeps going past a failing environment and ends with a ✓/✗ summary (--stop-on-error aborts at the first failure).

Under --stop-on-error the summary gains a third marker: – for an environment the batch never reached. Those appear only in that mode — without it every requested environment is attempted, so nothing is ever skipped.

To bring up a whole config root instead of named environments — creating the missing ones, without a prompt — use stack sync:

stack sync                      # every environment the root declares
stack sync env main scratch     # only these
stack sync --upgrade            # force new pins (the default preserves them)

To add tokens to a tracked project and bring it up to date in one step, use stack sync project from the project root:

stack sync project @qsar             # stack ["standard"] becomes ["standard", "@qsar"]
stack sync project @qsar --dry-run   # show the new stack and the delta first

It adds the tokens to the stack the project already records; it never replaces that stack, so an existing token cannot be lost by forgetting to repeat it. A token already recorded makes the run a plain re-resolve. The tokens are ordinary stack tokens, and --python, --strict, --no-sync, and --dry-run behave as they do for stack refresh, which remains the way to re-resolve the recorded tokens without adding any.

Changed behavior. --no-upgrade and --upgrade-package now genuinely preserve the pins the existing lock already holds. Previously both compiled into an empty file, so uv had no prior pins to keep and re-resolved everything. The first run after this change may therefore move pins that should have stayed put under the old behavior.

stack upgrade refuses a non-recreate upgrade when the running interpreter does not match python.txt — use stack create env NAME --recreate to resolve the mismatch first.

Changing an environment's Python version

To upgrade or downgrade an existing environment's interpreter:

stack create env NAME --python 3.14 --recreate

--recreate requires python.txt to hold a plain dotted version such as 3.14; a conda match spec such as 3.12.* is refused.

The command compiles the candidate lock before destroying the environment, so an unsatisfiable resolve leaves the old environment intact. micromamba.txt, channels.txt, and python.txt are applied at environment creation and --recreate only; stack upgrade re-syncs the pip layer without rebuilding the conda layer.

Inspecting

$ stack resolve standard ds numpy
bundle:standard
profile:ds
package:numpy

$ stack resolve --full standard
numpy
pandas
rdkit
rich

$ stack list profile --tag data       # only profiles tagged 'data'
$ stack show env main                 # python, tokens, channels, resolved packages

Comparing environments

stack diff dev prod                       # two environments here
stack diff dev ../laptop/envs/dev         # this machine vs another, all layers
stack diff dev ../from-laptop.lock.txt    # pins only, and it says so
stack diff dev prod --json
stack diff dev prod --exit-code           # exit 1 when they differ

Each source is an environment in this config root, the path to an envs/<name>/ directory from another machine, or a compiled requirements.lock.txt. A directory is read where it sits, so keep it outside this root's envs/, where it would become an environment of its own. An environment or such a directory carries four layers — the interpreter from python.txt, the micromamba packages, the effective channel order, and the compiled pins — and diff compares all four. A bare lock file carries pins only, so against one the other three are reported as not compared rather than as matching.

The interpreter, micromamba packages, and channels are declarations, not what is installed: two machines that both declare 3.12 can run different patch releases. So a match on those layers means the two environments are configured the same way; only the pins, which are resolved, show that the two resolved to the same Python distributions. diff reads what was compiled, not what is installed.

The verdict is one of three values. identical means every layer matched; identical-where-comparable means the pins matched but a bare lock hid the other layers; different means at least one layer differs. diff exits 0 in all three cases — a difference is a finding, not a failure — and exits 1 for different only under --exit-code. A source that cannot be read exits 1 either way.

Checking your setup

stack doctor

doctor never changes anything — it reports problems with a suggested fix: line for each. It covers the shape of the tree (missing directories, legacy file formats, environments in the wrong place, a name published as both a profile and a bundle) and its portability: declared variables with no value on this machine, references that are undeclared, malformed, or in an entry that may not hold one, editable checkouts that are absent (given as a path or a local file: URL; a relative path is looked up from the directory stack doctor runs in, where uv resolves it), a project-python.txt value that will not travel, and a missing or stale managed .gitignore block in a root that sits inside a git repository.

Watching for drift

stack status

Shows one row per shared environment: whether the micromamba env exists, whether a lock is present, and whether your sources changed since the last build (sources changed means "run stack upgrade"). When the running interpreter does not match python.txt, the state becomes python changed and the Python column shows both the configured and actual versions (e.g. 3.12 (env 3.14.0)). --json makes every inspection command (list, show, resolve, status, diff, doctor) script-friendly.

Typo protection

A bare token that matches no profile or bundle becomes a literal PyPI package. If it looks like a near-miss of one of your names, stack warns (did you mean 'standard'?). Add --strict to upgrade, create env, create project, create bundle, or resolve to turn any unqualified fallthrough into an error, and use the pkg: prefix to say "yes, really a package."

Shell completion

# zsh — add to ~/.zshrc:
eval "$(stack completion zsh)"

Tab-completes commands, options, and your environment/profile/bundle names.

Configuration reference

Config root

All state lives under one directory, resolved in this order:

  1. --root PATH (global flag)
  2. $UV_STACK_ROOT
  3. $UV_ENV_ROOT (the historical spelling, still accepted)
  4. ~/.config/python-envs (default)
~/.config/python-envs/
├── variables.txt             # optional: declares the names profiles may reference
├── variables.local.txt       # optional: this machine's values — do not commit
├── .gitignore                # written by `stack config portable`
├── project-python.txt        # optional: default --python for `create project`
├── editor.txt                # optional: editor command for `stack edit`
├── profiles/
│   └── <name>.yaml
├── bundles/
│   └── <name>.yaml
├── .locks/                   # internal lock files (safe to leave alone)
└── envs/
    └── <name>/
        ├── stack.txt             # required (defines the env)
        ├── python.txt            # optional
        ├── micromamba.txt        # optional
        ├── channels.txt          # optional
        ├── requirements.local.in # optional
        ├── requirements.in       # generated
        ├── environment.yml       # generated
        └── requirements.lock.txt # generated

The root, or a directory above it, can be a symbolic link, for example to a checkout kept elsewhere. If the link's target is missing, commands that write refuse rather than create an empty tree in its place. Restore the target (clone or mount it), or run stack config init to create it; stack init offers to create it as well.

Shell environment variables

Variable Purpose
UV_STACK_ROOT Config root (overridden by --root)
UV_ENV_ROOT The historical spelling of UV_STACK_ROOT; used only when UV_STACK_ROOT is unset or empty
UV_STACK_PROJECT_PYTHON Default interpreter spec for stack create project
UV_STACK_EDITOR Editor command for stack edit; beats editor.txt, $VISUAL, and $EDITOR
VISUAL Standard fallback editor for stack edit, below editor.txt
EDITOR Standard fallback editor for stack edit, consulted last
MAMBA_EXE Path to the micromamba binary; set by micromamba shell init and preferred over PATH lookup

Portable config roots

A config root is a directory of text files, so it can live in git and be cloned onto another machine. Two things make that work: a managed .gitignore block that keeps generated files out of the repository, and variables that let one profile describe paths that differ per machine.

What travels

Travels Does not travel
profiles/, bundles/, envs/*/stack.txt, python.txt, micromamba.txt, channels.txt requirements.in, environment.yml, requirements.lock.txt (all generated)
variables.txt — the declared names variables.local.txt — this machine's values
project-python.txt, when it holds a portable selector envs/*/requirements.local.in — one machine's extra packages
editor.txt — this machine's editor
.locks/, .DS_Store

Compiled locks deliberately do not travel. A lock is resolved for one platform and one interpreter, and uv-stack compiles per machine rather than committing a lock that is only correct where it was produced. The consequence is worth stating plainly: two machines converged from the same sources will have compatible environments, not identical pins. stack status is a single-machine command; stack diff reports the difference between two machines once one of them has the other's environment directory to read (see below).

Bring-up on a new machine

git clone <url> ~/.config/python-envs
stack doctor                                    # names the variables with no value
$EDITOR ~/.config/python-envs/variables.local.txt
stack sync                                      # builds every environment

Step two is what makes this work on a machine you have never set up: doctor reads the declared list and reports what is missing before anything tries to build.

To check the result against a machine that is already set up, bring that machine's envs/<name>/ directory over by any means, including its requirements.lock.txt — the clone deliberately left the lock behind. Put it anywhere outside this root's envs/, where it would become an environment of its own, and compare:

stack diff main ../other/envs/main

Making a root committable

stack config portable

This writes a managed block into <root>/.gitignore, delimited by markers:

# BEGIN uv-stack — managed block, do not edit by hand.
...
# END uv-stack

Edit the file freely outside the markers — your lines are preserved. Anything you write inside them is replaced the next time the command runs.

A .gitignore that is a symlink is refused rather than written or replaced: git commits the link itself, so the rules it points at would never reach the machine that cloned the root.

A .gitignore that carries a second hard link is refused too. A hard link can be created without permission to read what it points at, so reading through one could copy a file you can read — and whoever created the link cannot — into a file you are about to commit. The same refusal catches an innocent case: a hardlink snapshot backup such as cp -al or rsnapshot leaves every file it covers with a second link, so a .gitignore under one must be de-linked (copy it to a new path and move it back) before the command will refresh it.

An empty profiles/, bundles/, or envs/ also gets a .gitkeep. Git tracks files and not directories, so without one the directory would simply be absent from the clone, and the bring-up above would report it as a missing directory. Only a directory that already exists and holds nothing gets a placeholder — creating a directory is stack config init's job, not this command's.

The command prints the git commands to follow, and for a root that is already a repository those begin with an untracking step: files that are now ignored stay tracked until git rm --cached removes them from the index. That applies to any root that is a repository, including one whose .gitignore this command is creating for the first time. A root that is a subdirectory of a repository — python-envs/ inside a dotfiles repo — counts as one: every command is addressed to the config root with -C, and git finds the enclosing repository by walking up from there.

That sequence stages only the .gitignore it just wrote, so nothing else sitting in the root is committed on your behalf; a root whose own files have never been committed needs a git add of your own. Its commit records everything already staged in the repository, this root or not, which is worth a git status before you paste it.

uv-stack never invokes git. It writes the ignore file and prints the commands; you run them.

Variables

variables.txt declares the names, one per line, comments allowed:

# Root of the development checkouts this root's editables point into.
DEV
WHEELS

variables.local.txt supplies this machine's values and is not committed:

DEV=~/src
WHEELS=/opt/wheels

An exported environment variable of the same name wins over the file, which is what makes a CI runner work without writing a file at all. Only declared names are read from the environment.

Which project-python.txt values travel

Form Example Travels
Python version 3.12 yes
uv implementation form cpython@3.12, pypy-3.10 yes
An environment this root declares main yes
An environment it does not declare scratch no
An interpreter path /opt/envs/x/bin/python no

A path is the one form that reaches uv unchanged and still fails to travel, which is why stack doctor warns about it separately. The same check applies to --python values recorded in a project's [tool.uv-stack] table: uv-stack records what you gave it, and says so when the value will not travel.

The limit on tracked projects

uv-stack keeps its own project ledger portable — [tool.uv-stack] records requirement strings unexpanded, so a committed pyproject.toml never holds another machine's home directory.

It cannot keep uv's half. uv add writes the resolved absolute path of a local source into the project's own [tool.uv.sources] table, and uv exposes no flag to keep that symbolic. So a project whose stack resolves to ordinary named requirements is portable, and a project whose stack pulls in any local source — an editable or a plain path — is machine-bound in that table. Closing that gap would mean rewriting uv's output behind its back.

Moving environments between machines

stack export

stack export [ITEMS...] writes the named environments, profiles, and bundles as a single JSON document. Each ITEM is env:NAME, profile:NAME, bundle:NAME, @NAME, or a bare NAME that names exactly one kind. With no ITEMS, the whole config root is exported.

Everything the named items reach is included: all referenced profiles and bundles, plus each environment's lock file as a seed. The document goes to standard output by default; pass -o FILE to write it to a file instead.

When standard output is the destination, only the document is written there. Warnings (such as the absolute-path notice below) go to standard error so they do not corrupt the JSON stream.

Absolute paths are not portable. If a profile or environment includes an editable install (-e /src/foo), a local path (/wheels/bar.whl), or a file:// URL, the export warns once per file: those paths must exist at the same locations on the target machine for its environments to build. The import writes the definitions either way. Its pre-flight refuses a missing editable checkout, given as a path or a local file: URL, before anything is written (unless --no-build), and uv reports any other missing path when the build runs. Use ${NAME} variable references to make paths portable across machines.

References are matched by spelling. On a filesystem that ignores letter case, such as a default macOS install, a stack file's foo reaches profiles/Foo.yaml, and the export ships it as profiles/foo.yaml, the key the reference names. An export that would ship that file under both spellings is refused and names both; spell the reference as the file is named.

Pull from another machine. You can export and import in one command by piping over ssh:

ssh HOST stack export ITEMS | stack import -

stack import

stack import FILE|- installs the environments, profiles, and bundles from a document written by stack export. Pass the document's file path, or - to read from standard input.

Each file in the document is installed as new, left alone if identical to this machine's copy, replaced when --overwrite is given and the content differs, or removed (for environment files only) when the source environment no longer has it and --overwrite is given.

Conflicts refuse the import. When a file differs from this machine's copy and --overwrite was not given, the import stops before writing anything, prints a unified diff showing the difference, and lists the environments that use the file. Fix the conflict manually or re-run with --overwrite.

Meaning changes are always refused. An unqualified token in a stack file or bundle means whichever profile, bundle, or package it resolves to, and the import refuses to change that meaning in either direction, even with --overwrite:

  • A token in a shipped stack file or bundle must mean here what it meant on the source machine. A shipped environment whose stack names utils as a package is refused when this machine has a utils profile.
  • A token in one of this machine's stack files or bundles that the import does not replace must mean the same after the import. Importing profile:utils is refused when an existing environment's stack names utils as a package.

Fix it by qualifying the token so it keeps its meaning (pkg:utils for the package, profile:utils, or @utils for a bundle); the refusal's hint names the form to write. Qualify this machine's own file in place; for a shipped file, qualify it on the source machine and export again.

Options:

  • --overwrite: Replace files that differ and remove environment files that the source environment no longer has. Without this flag, differing files stop the import with a conflict error.
  • --dry-run: Report what would change without writing anything. Unless --no-build is given, the pre-flight below still runs, which can probe micromamba, and each build the import would run is listed as build: NAME (ACTION), where ACTION is create, sync, or recreate.
  • --strict: Refuse unqualified names in stack files and bundles that fall through to package literals rather than referencing a profile or bundle.
  • --no-build: Install the definitions without building environments.
  • --recreate: Wipe and rebuild each imported environment from the shipped pins. Only works with plain dotted Python versions (e.g., 3.12); refused for ranges or constraints. Cannot be combined with --no-build.

Variables. Variable names the imported definitions reference but this machine does not declare are appended to variables.txt, names only, and listed on a declare in variables.txt: line. The document does not carry their values: set each in variables.local.txt as NAME=value, or export it in the environment.

Paths. Writing the definitions checks none of the paths they name; the build needs them. A relative editable path resolves against the directory stack runs in, which is where uv resolves it, not against the config root. Under stack sync remote that directory is the remote user's home.

Pre-flight refusals. Unless --no-build is given, the import checks each imported environment before writing anything, and refuses when it references a variable with no value here, installs an editable checkout (a path or a local file: URL) that is missing here, already runs a Python outside the plain version its imported python.txt names (without --recreate; 3.12 when the document ships no python.txt), or has a python.txt that is not a plain version (with --recreate). Each refusal's hint also offers --no-build. uv reports any other missing path during the build.

Build phase. After writing the definitions, each imported environment is built: created if absent, recreated if --recreate is given, or synced otherwise. The environment's shipped lock seeds its compile, so those pins act as preferences and uv re-resolves them for this machine. For each environment that shipped a lock and built, uv-stack then prints a pin report:

main: 41 pins kept, 1 changed, 0 dropped, 1 added
  changed: numpy 1.26.4 -> 2.0.2
  added: typing-extensions 4.12.2

kept counts the shipped lock's version and URL pins that the new lock holds unchanged. changed, dropped, and added count requirements whose pin differs, that only the shipped lock has, or that only the new lock has. An editable or unnamed path requirement in both locks is in none of the four, so kept, changed, and dropped need not add up to the number of requirements in the shipped lock. An environment exported without a lock prints no report.

Environments not rebuilt. Environments on this machine that the document does not ship are never rebuilt. When the import replaces a profile or bundle they use, or ships a new profile that captures a bare token in an unchanged bundle they reach, a line names them:

Not rebuilt, but using changed definitions: work. Rebuild them with 'stack sync env work'.

The line prints whenever such environments exist: after a successful or failed build, with --no-build, and with --dry-run. It can print even without --overwrite, since adding a new profile replaces nothing.

Build failures. If a build fails, the definitions stay written, the remaining environments still build, and the import exits 1 after naming only the failed ones: Re-run the failed build(s) once the cause is fixed: stack sync env NAME. That command syncs; it does not recreate. After a failed --recreate build, re-run the same import with --recreate instead, which is safe: the files already written are reported as identical, and each imported environment is recreated again, including those that built.

stack sync remote

stack sync remote [ITEMS...] DEST exports the named items (or the whole root if no ITEMS are given) and imports them on a remote machine, running:

ssh DEST <stack> [--root ROOT] import - [FLAGS]

The document is sent to ssh's stdin; ssh's stdout and stderr stream to the local terminal as they arrive; the local exit status is the remote's. Everything the import does—pre-flight checks, building environments, pin reports—happens on the remote and prints locally.

Printed commands are for the remote. The stack sync env ... commands the import prints refer to the remote machine: the re-run command for environments whose build failed, and the "Not rebuilt, but using changed definitions" line naming the remote's other environments that use a changed profile or bundle. The second line can appear even when every build succeeds, and with --no-build or --dry-run. Run these commands on the remote (for example over ssh DEST), not locally. The printed commands use bare stack and the remote's default root; when the stack command or root was customized, run them with the same command and root the import used—for example, ssh gpu-box 'PATH=$HOME/.local/bin:$PATH stack --root /data/python-envs sync env NAME' for the remotes.yaml entry below.

Customizing the remote command. --remote-stack CMD sets the stack command to run on the remote. --remote-root PATH sets the remote's config root. Both can be specified per-host in remotes.yaml (see below), with the command-line flags taking precedence.

The remote's PATH. ssh runs the command in a non-interactive shell, whose PATH often lacks ~/.local/bin (where uv tool install and the micromamba installer put their commands) or Homebrew's /opt/homebrew/bin, because the startup files that add them are skipped or return early for such shells. Finding stack is not enough: the import runs uv from PATH, and micromamba from $MAMBA_EXE when that is set, otherwise from PATH. The stack command is inserted into the remote command line as written, so a PATH= prefix in it reaches stack and everything stack runs:

gpu-box:
  stack: PATH=$HOME/.local/bin:$PATH stack

List every directory that holds stack, uv, or micromamba on that host, for example PATH=$HOME/.local/bin:/opt/homebrew/bin:$PATH stack, or set MAMBA_EXE=/path/to/micromamba in the same way. A prefix needs a remote login shell that accepts NAME=value command, as sh, bash, and zsh do.

Exit status hints:

  • Exit 255 is ssh's own failure: the connection failed, or it dropped during the import. The hint says that re-running the same command is safe: files already written are reported as identical and the rest are completed. If the dropped import is still running on the remote, a re-run waits a few seconds for its lock and then refuses.
  • Exit 127 means the remote shell did not find the stack command. The hint suggests uv tool install uv-stack on the remote and, for an installed uv-stack that the ssh shell cannot find, a remotes.yaml entry for DEST whose stack is the PATH recipe above.
  • Exit 2 with "No such command 'import'" in the stderr means the remote's uv-stack predates import; the hint says to run uv tool upgrade uv-stack there.

Options forwarded to the remote:

  • --overwrite: Replace files that differ and remove environment files the source no longer has.
  • --no-build: Install definitions without building environments.
  • --recreate: Wipe and rebuild each environment from the shipped pins.
  • --dry-run: Report what the remote would change without writing anything. Unless --no-build is given, this can still probe the remote's micromamba installation.
  • --strict: Refuse unqualified names that fall through to package literals.

Using ssh prompts. Password and passphrase prompts still work—ssh reads them from the terminal, not stdin.

Without a remote uv-stack install. For a remote that has uv but not uv-stack, run it through uvx. The stack it starts runs uv too, so give the command the same PATH prefix:

gpu-box:
  stack: PATH=$HOME/.local/bin:$PATH uvx --from uv-stack stack

Pull from another machine. To pull items from a remote into the local machine, reverse the direction by piping the remote export into a local import:

ssh HOST stack export ITEMS | stack import -

remotes.yaml

A root-level file mapping host names to optional settings:

gpu-box:
  stack: PATH=$HOME/.local/bin:$PATH stack
  root: /data/python-envs

Each entry allows only stack (the command to run on the remote) and root (the remote's config root), both optional non-empty strings. Command-line flags (--remote-stack, --remote-root) take precedence over the file's values.

  • DEST must match the key exactly as typed. stack sync remote gpu-box uses the entry above; stack sync remote user@gpu-box does not.
  • root is passed literally. It reaches the remote quoted, and only a leading ~ is expanded there. $HOME/envs is not expanded: it names a directory literally called $HOME, relative to the remote home. stack, by contrast, is inserted unquoted, so the remote shell expands ~ and $HOME in it.
  • Quote host names that YAML reads as something else. A key such as yes, no, null, or 1 is a boolean, null, or number to YAML, and stack sync remote refuses the file until it is quoted ("yes":). stack config remote set quotes such names itself.
  • List each host once. YAML would silently keep only the last of two entries for one host, so every command that reads the file refuses it and names both lines. "gpu-box" and gpu-box are the same host.
  • Write each setting once per entry. YAML uses only the last of two root: lines in one entry, so commands that read the file warn and name both lines. stack config remote set and remove keep the used one when they rewrite the file.

Two commands change the file. stack edit remotes opens it in your editor and validates it on exit (see Editing configuration). stack config remote set HOST --stack CMD --root PATH merges the given fields into HOST's entry, creating it if needed. stack config remote remove HOST deletes the entry, and remove HOST stack or remove HOST root clears only that field, leaving HOST: {} when none remain. stack config remote list prints each entry with an unset field shown as its default. set and remove rewrite the whole file, normalizing its quoting and indentation, so they refuse a file that holds comments, which the rewrite would drop; edit a commented file with stack edit remotes instead.

The file is portable across machines—the remote's paths are properties of the remote, not of the machine that pushes. stack doctor reports an unreadable or invalid remotes.yaml file.

Tips and gotchas

  • Edit sources, not generated files. requirements.in, environment.yml, and requirements.lock.txt are overwritten on every upgrade. For env-specific additions use requirements.local.in; for anything reusable, a profile.
  • --dry-run is almost dry. For stack upgrade, stack sync and stack sync env, --dry-run runs no uv commands and never touches the lock or the environment, but it re-renders requirements.in and environment.yml and can still ask micromamba for the environment's Python version. stack refresh --dry-run and stack sync project --dry-run change nothing. stack import --dry-run and stack sync remote --dry-run write nothing, but can still run micromamba probes (on the remote, over ssh, for stack sync remote).
  • Import's letter-case checks assume matching filesystems. stack import stages its view of the result in the system temporary directory, and its checks of whether two names that differ only in letter case or Unicode normalization are one file see that directory's filesystem. They are exact only when it folds names the same way as the config root's filesystem, as a default macOS install or a plain Linux system does. On a target that folds case, a shipped case variant of an existing definition replaces it, and the environments that used it are reported under the shipped name.
  • Your lock is safe from failed compiles. The lock is compiled to a temporary file and atomically swapped in, so a failed uv pip compile never corrupts an existing requirements.lock.txt. If the later sync step fails (e.g. a network blip), just run stack upgrade NAME again.
  • Activation is micromamba's job. stack builds shared environments; you enter them with micromamba activate <name> or run one-offs with micromamba run -n <name> <command>.
  • Name collisions. A bare token prefers a profile over a bundle over a literal package. When a package name collides with one of your profile or bundle names, force the package with pkg:<name>. A name that is both a profile and a bundle still works — bare reaches the profile, @<name> the bundle — but stack doctor warns about it, because otherwise you only hear about it on the runs that happen to reference the name.
  • doctor --fix is conservative. It only applies safe repairs (creating missing directories, renaming legacy files, converting .in/.bundle files to YAML with a .bak backup). Anything destructive stays a printed suggestion.
  • Migrating old configs. If you previously used .in profiles, .bundle files, or a profiles.txt, stack doctor will point at each leftover and tell you what to rename or convert.
  • The [tool.uv-stack] table is tool-owned. stack refresh rewrites it wholesale; comments inside that one table are not preserved. uv-stack's own writes leave every other byte of pyproject.toml alone — but refresh also runs uv remove/uv add under the hood, which edit [project.dependencies] just as they would if you ran them yourself. An interrupted refresh or tracked create may leave a pending key in the table; the next successful stack refresh (or re-running the tracked create with --force) cleans it up.
  • A value in variables.local.txt cannot contain #. The file grammar strips comments, so everything from the first # is discarded. An exported environment variable has no such restriction.
  • A value holding ${...} is refused, not passed through. Substitution is single-pass, so a ${OTHER} inside a value is never itself expanded — and an entry that still holds ${ afterwards is rejected rather than written out, because uv expands environment variables in a requirements file and would resolve the leftover reference behind uv-stack's back. DEV=/a/${OTHER}/b turns every entry that references DEV into an error: stack doctor reports it and stack sync refuses.
  • No value may contain whitespace. That rules out a path containing a space, and it is what keeps expansion from splitting an already-validated entry into a new token. Use a symlink or move the checkout.

Metadata

Release files for uv-stack 0.8.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for uv-stack 0.8.0
File Interpreter ABI Platform
uv_stack-0.8.0-py3-none-any.whl Python 3 none any Details

Release files / uv_stack-0.8.0-py3-none-any.whl

Download URL uv_stack-0.8.0-py3-none-any.whl
Size 254.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
27ff176760b5444c33ef3a26eb7ed51fd872e488c6187dffa93322840c64bef9
BLAKE2b-256 checksum
How to use checksums
0aca3999debc26220be20b1efb428e668ee9837091107559a193b155e9cc086f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.7

Release history Release notifications | RSS feed

This release

0.8.0 This release

1 release file

0.5.0

1 release file

0.4.4

1 release file

0.1.6

1 release file

0.1.5

1 release file

0.1.4

1 release file

0.1.3

1 release file

0.1.2

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page