Written and maintained by CASRAI Editorial Board
Last updated
A plain conda env export looks like it should be enough to hand a working environment to a collaborator, a CI runner, or your own future self. It usually isn’t. The resulting environment.yml is tied to the exact machine that generated it in ways that only surface when someone else tries to rebuild from it — a solve failure, a package that silently resolves to a different version, or an environment that builds but behaves differently at runtime. This guide covers why that happens and what actually produces a rebuildable environment: exporting with --from-history plus deliberate pinning, generating a real multi-platform lock file with conda-lock, and the limits that remain even when you do both correctly.
Why a Plain conda env export Often Fails Elsewhere
Running conda env export > environment.yml captures the exact solved state of your current environment — every package, every transitive dependency, pinned to an exact version and build string. That completeness is the problem, for three distinct reasons.
Platform-specific build strings
A default export pins each package with a build string like numpy=1.26.4=py311h_openblas_0. That build string encodes the compiler, the platform, and often the specific linked libraries (OpenBLAS vs. MKL) used to build that exact binary. Conda-forge and the defaults channel publish separate builds per platform (linux-64, osx-64, osx-arm64, win-64), and a build string from one platform frequently does not exist on another. A teammate on macOS trying to recreate a linux-64-exported environment will often see conda refuse to solve at all, with no obvious explanation in the error output.
Unpinned transitive dependencies drift over time
Paradoxically, the opposite failure also happens: two exports taken weeks apart from environments that started identical can diverge, because neither pinned the full dependency graph explicitly by hand — conda re-solved transitive dependencies against whatever was newest in the channel at export time. A full export pins what conda resolved then, not a stable specification you can regenerate identically later without the same lock artifact.
Channel priority and channel order change what gets installed
The same package name can exist in both conda-forge and defaults with different version numbers and different dependency trees. Which one conda picks depends on channel order in the environment file and on whether channel_priority is set to strict or flexible on the installing machine. An environment.yml that omits an explicit channels: block, or that relies on whatever channel priority happens to be configured globally on the author’s machine, can solve to genuinely different packages on someone else’s system even when every version number in the file matches.
Step One: Export With --from-history, Then Pin Deliberately
The fix for the “captures too much, in the wrong way” problem is to stop exporting the full solve and instead export only what you explicitly asked conda to install:
conda env export --from-history > environment.yml
--from-history writes back only the packages you actually named in conda install/conda create commands, without the version and build pins conda added while solving. This produces a much shorter, more portable file — but on its own it under-specifies the environment, since it leaves conda free to solve to whatever version is current when the file is used later. The workable pattern is to take that trimmed history export and manually add explicit version pins for anything that actually matters to your results:
name: myproject
channels:
- conda-forge
- nodefaults
channel_priority: strict
dependencies:
- python=3.11
- numpy=1.26
- pandas=2.2
- scikit-learn=1.4
- pip
- pip:
- some-pypi-only-package==0.9.2
Three details in that file matter more than they look:
- An explicit
channels:list, in order — don’t rely on a reader’s global.condarcto supply the right channel.nodefaultsexplicitly excludes thedefaultschannel, which matters because conda-forge and defaults can resolve the same package name differently. channel_priority: strictin the file itself (or set globally withconda config --set channel_priority strictand documented as a prerequisite) — this makes conda satisfy a dependency from the highest-priority channel that has it at all, rather than mixing packages across channels to get the newest version of each individually, which is what produces subtly incompatible package combinations.- Pin only versions you actually care about, not build strings. A version pin like
numpy=1.26is portable across platforms; a build-string pin likenumpy=1.26.4=py311h_openblas_0generally is not.
Recreate it with:
conda env create -f environment.yml
This gets you a file that’s readable, diffable in version control, and solvable on more than one platform — but conda still re-solves the full dependency graph from your pins at install time, and a solve run today can pick different transitive-dependency versions than a solve run six months from now, even with identical pins on the packages you named. For anything where exact reproducibility matters — a published analysis, a regression test suite, a shared compute environment several people need to match byte-for-byte — a hand-pinned environment.yml alone isn’t the end state. That’s what a lock file is for.
conda-lock: A Real, Fully-Pinned Lock File
conda-lock solves the dependency graph once, records the exact resulting package set — including exact versions and hashes — and writes it to a lock file that every later install reads from directly instead of re-solving. It also solves separately per target platform in a single command, which is the direct fix for the build-string problem above.
Install it, then generate a lock file from your environment.yml, specifying every platform you need to support:
pip install conda-lock
conda-lock lock -f environment.yml -p linux-64 -p osx-64 -p osx-arm64 -p win-64
This solves the environment independently for each named platform and writes a single unified conda-lock.yml covering all of them — this is the file to commit to version control alongside environment.yml, not a replacement for it. A collaborator on any of the specified platforms then installs the exact locked package set with:
conda-lock install -n myproject conda-lock.yml
(or conda-lock install -p /path/to/env conda-lock.yml to install into an explicit prefix). Because the lock file already carries a fully solved, per-platform package list, this step does not invoke conda’s solver at all — it installs exactly the recorded set, which is what makes it reproducible in the strict sense that a hand-pinned environment.yml alone is not.
An Alternative for a Single Platform: Explicit Spec Files
If every consumer of the environment is on the same operating system and architecture as you — a shared lab workstation, a fixed CI runner image, a single HPC cluster — conda’s own explicit spec files are a lighter option than adopting a separate tool:
conda list --explicit > spec-file.txt
conda create --name myproject --file spec-file.txt
An explicit spec file lists a fully-resolved download URL for every package, so recreating from it does not invoke the solver at all — it is exact, but only on a matching platform. Conda’s own documentation is direct about this: an explicit spec file is not generally cross-platform, which is exactly the case conda-lock‘s per-platform solving is built to handle.
What Pinning Still Can’t Fix
Even a correctly generated, fully-pinned, multi-platform lock file has real limits worth stating explicitly rather than discovering later:
- Some packages simply aren’t built for every platform. A package with Linux-only system dependencies, or one that has never had a native
osx-arm64(Apple Silicon) build published, can’t be solved for that platform no matter how carefully you pin — the fix is a platform-conditional dependency, a different package, or dropping that platform from your supported list. - Hardware-linked numerical libraries can still change results at the margins. Locking the package version doesn’t guarantee identical floating-point behavior across CPU architectures for BLAS/LAPACK-backed operations (NumPy, SciPy, PyTorch) — different underlying instruction sets can produce tiny numerical differences even from the same nominal library version, which matters for exact bit-for-bit reproducibility even if it rarely matters for a paper’s actual conclusions.
- pip-installed packages inside a conda environment sit outside conda’s own dependency resolution and need their own pinning discipline (exact versions in the
pip:block, ideally with hashes) — conda-lock does capture these, but a hand-maintainedenvironment.ymlwithout conda-lock generally does not pin them as tightly as the conda-managed packages above them. - A lock file only reproduces the software environment, not the operating system, GPU driver, or hardware it runs on. For reproducibility guarantees that also need to hold the OS and system libraries constant, a lock file is the right layer to combine with a container image, not a substitute for one — see the container-based approach on this site’s guide to reproducibility infrastructure.
A Practical Checklist for Sharing an Environment
- Export with
conda env export --from-history, then hand-add version pins (not build-string pins) for anything that affects your results. - Add an explicit
channels:block, in order, and setchannel_priority: strict. - Generate a
conda-lock.ymlfor every platform your collaborators or CI actually run on, and commit both files —environment.ymlas the human-readable specification,conda-lock.ymlas the exact install artifact. - Record which conda/mamba version and solver you used, especially if you hit a solve that only succeeds with one solver.
- If bit-for-bit numerical reproducibility across different CPU architectures genuinely matters, don’t rely on the conda environment alone — pair it with a container image pinned by digest.
Frequently Asked Questions
Does --from-history alone make an environment reproducible?
No. --from-history fixes the over-specification problem (platform-specific build strings, an unreadable full transitive dependency dump) but under-specifies on its own, since it omits the version pins conda resolved. Combine it with manual version pins on the packages that matter, or use conda-lock for a fully pinned result.
What’s the actual difference between environment.yml and conda-lock.yml?
environment.yml is a specification — a human-edited list of what you want installed, with version pins you chose. conda-lock.yml is a solved result — the exact package set, per platform, that a solve of that specification produced at generation time. Installing from the lock file skips solving entirely; installing from environment.yml alone re-solves and can drift.
Can conda-lock handle pip dependencies mixed into a conda environment?
Yes — conda-lock resolves the pip: section of an environment.yml alongside the conda packages and records it in the generated lock file, which is one of its advantages over a conda-only explicit spec file.
Does using mamba instead of conda change any of this?
Mamba is a faster solver implementation, not a different reproducibility model — the same pinning and lock-file guidance applies whether you invoke conda or mamba commands. conda-lock itself can call mamba for faster solves; it’s controlled with the --mamba/--no-mamba flag on conda-lock install.
If I just used a Docker or Apptainer container, would that make lock files unnecessary?
Not unnecessary — complementary. A container pins the operating system and system libraries that a conda lock file doesn’t touch, but something still has to specify what gets installed inside that container, and a pinned conda environment (or lock file) is exactly that specification. See Apptainer vs Docker for choosing a container runtime for HPC research, and the site’s container image entry for how the two layers fit together.
Why did my environment solve successfully but produce slightly different results than a colleague’s?
Check first for an unpinned transitive dependency that resolved differently at install time on each machine, then for a platform-specific numerical library difference (see “What Pinning Still Can’t Fix” above) if the versions genuinely match. A conda list diff between the two environments is the fastest way to tell which case you’re in.
For the broader reproducibility landscape this fits into — workflow managers, container images, and code-sharing conventions beyond the environment file itself — see this site’s guide to reproducibility infrastructure, its Snakemake vs Nextflow comparison for pipeline-level reproducibility, and the computational reproducibility definition for how this fits the broader reproducibility taxonomy. For managing versioned datasets alongside a pinned code and software environment, see Data Version Control (DVC) for research datasets and ML pipelines. For the full landscape of research software and lab-informatics tooling, see the Research Tools & Software hub.








