Declarative Hugging Face model and dataset management for Nix. nix-hug pins
models to exact revisions, fetches only the files you need, builds
offline-compatible HuggingFace Hub caches, and supports importing from and
exporting to the local HuggingFace cache.
The CLI is used to download models into the nix store:
$ nix run github:eordano/nix-hug -- fetch MiniMaxAI/MiniMax-M2.5
nix-hug-lib.fetchModel {
url = "MiniMaxAI/MiniMax-M2.5";
rev = "abc123...";
fileTreeHash = "sha256-...";
gitRepoHash = "sha256-...";
};The output can then be used in nix:
# Smoke test: an app that just loads the model in python
let
minimax = nix-hug-lib.fetchModel {
url = "MiniMaxAI/MiniMax-M2.5";
rev = "abc123...";
fileTreeHash = "sha256-...";
gitRepoHash = "sha256-...";
};
cache = nix-hug-lib.buildCache {
models = [ minimax ];
};
python = pkgs.python3.withPackages (p: [ p.transformers p.torch ]);
in
pkgs.writeShellApplication {
name = "say-minimax-inefficiently";
runtimeInputs = [ python ];
text = ''
export HF_HUB_CACHE=${cache}
export TRANSFORMERS_OFFLINE=1
python -c "
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained('MiniMaxAI/MiniMax-M2.5')
print(model)
"
'';
}Add nix-hug to your flake inputs:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
nix-hug.url = "github:eordano/nix-hug";
};
}Use the CLI to fetch a model. It resolves the revision, computes hashes, and prints a Nix expression you can paste into your configuration:
$ nix-hug fetch mistralai/Mistral-7B-Instruct-v0.3 --include '*.safetensors'Use the output in your flake to build an offline HuggingFace Hub cache:
let
nix-hug-lib = nix-hug.lib.${system};
mistral = nix-hug-lib.fetchModel {
url = "mistralai/Mistral-7B-Instruct-v0.3";
rev = "abc123..."; # pinned commit hash from CLI output
filters = { include = [ ".*\\.safetensors" ]; };
fileTreeHash = "sha256-...";
gitRepoHash = "sha256-...";
};
cache = nix-hug-lib.buildCache {
models = [ mistral ];
};
in
pkgs.mkShell {
HF_HUB_CACHE = cache;
TRANSFORMERS_OFFLINE = "1";
}Running Python within this shell will find the model without network
access (the transformers lib reads the env variable HF_HUB_CACHE):
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("mistralai/Mistral-7B-Instruct-v0.3")nix-hug has two parts: a bash-based CLI, and a nix library. The CLI's fetch
subcommand resolves the git ref to a commit hash via the Hugging Face API. It
then fetches the repository's file tree metadata and computes a SHA256 hash of
how the directory structure for consumption by HuggingFace libraries will look
like. The output of the CLI is a Nix expression that pins that "fileTreeHash"
and stores the git ref.
When consuming it, the nix-based lib evaluates that expression, and executes
the same steps that the bash-based CLI does: fetchgit clones the Hugging Face
repository at the pinned revision and gitRepoHash, at build time rather than
evaluation time. This retrieves all small files (configs, tokenizer data, etc.)
but only LFS pointer files for large weights. For each LFS file then fetchurl
downloads it from HuggingFace's CDN using the LFS SHA256 OID as the content
hash. Filters can be provided to selectively download some of these large
files, in case the repository contains a lot of model files that you don't need
(for example, one might want only one particular large ".safetensors" file from
a repository that has also ONNX files, or many quantizations together in the
same repo). A derivation assembles the result: the git checkout with real model
files replacing the LFS pointers.
buildCache takes fetched models and datasets and arranges them into the
directory layout that HuggingFace Hub's Python libraries expect:
models--org--repo/
refs/
main # contains the pinned commit hash
snapshots/
<rev>/ # the actual model filesSet HF_HUB_CACHE to this store path and any library that reads from the Hub
cache (transformers, diffusers, sentence-transformers) will find the
model without making network requests. Please note that datasets is known to
cause problems sometimes (contributions welcome).
Everything is content-addressed. The same inputs produce the same store paths. Models can be shared across machines, cached in CI, and pinned in lockfiles the same way as any other Nix dependency.
nix-collect-garbage removes store paths not referenced by a GC root. For
large models, re-downloading after collection is expensive. The export
command copies a model from the Nix store into the local HuggingFace cache
directory, and import copies it back. This uses the same directory layout
that transformers, diffusers, and other HF libraries read from. The cache
location is determined by $HF_HUB_CACHE, $HF_HOME/hub, or defaults to
$XDG_CACHE_HOME/huggingface/hub/.
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
nix-hug.url = "github:eordano/nix-hug";
};
outputs = { nixpkgs, nix-hug, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
nix-hug-lib = nix-hug.lib.${system};
my-model = nix-hug-lib.fetchModel {
url = "stas/tiny-random-llama-2";
rev = "3579d71fd57e04f5a364d824d3a2ec3e913dbb67";
fileTreeHash = "sha256-mD+VYvxsLFH7+jiumTZYcE3f3kpMKeimaR0eElkT7FI=";
gitRepoHash = "sha256-SrdDsqK7grmWiB0nH4q78jUyGTta3ZX8UXuZCEhPwOw=";
};
model-cache = nix-hug-lib.buildCache {
models = [ my-model ];
};
in {
packages.${system} = {
inherit my-model model-cache;
default = nix-hug.packages.${system}.default;
};
devShells.${system}.default = pkgs.mkShell {
buildInputs = [ nix-hug.packages.${system}.default ];
};
};
}$ nix run github:eordano/nix-hug -- fetch mistralai/Mistral-7B-Instruct-v0.3Global options:
--debug: enable verbose logging--version: print version--help: show help
Downloads a model or dataset from Hugging Face and prints a Nix expression with pinned revision and hashes.
$ nix-hug fetch <url> [options]Options:
--ref REF: git reference to resolve (default:main)--include PATTERN: include files matching a glob pattern--exclude PATTERN: exclude files matching a glob pattern--file FILENAME: include a specific file by name--lfs-url URL: LFS download URL prefix, forgit+URLs whose LFS server differs from the git remote--vendor DIR: write the file tree, or the LFS pointer list for agit+URL, toDIRand emit an expression that evaluates without network access (see Offline evaluation)--dry-run: show what would be fetched without downloading
# Fetch only safetensors weights
$ nix-hug fetch mistralai/Mistral-7B-Instruct-v0.3 --include '*.safetensors'
# Fetch a dataset
$ nix-hug fetch rajpurkar/squad --include '*.json'
# Fetch a single config file
$ nix-hug fetch google-bert/bert-base-uncased --file config.json
# Emit an expression that needs no network at evaluation time
$ nix-hug fetch openai-community/gpt2 --vendor ./treesThe CLI auto-detects whether a repository is a model or dataset by querying the Hugging Face API.
Lists files in a repository without downloading anything. Accepts the same
filter options as fetch.
$ nix-hug ls mistralai/Mistral-7B-Instruct-v0.3
$ nix-hug ls stanfordnlp/imdb --include '*.parquet'Fetches a model or dataset and copies it into the local HuggingFace cache
directory. This makes the model available to transformers, diffusers, and
other HF libraries, and preserves it outside the Nix store (surviving garbage
collection).
Accepts the same filter options as fetch.
$ nix-hug export openai-community/gpt2
$ nix-hug export openai-community/gpt2 --include '*.safetensors'Imports a model or dataset from the local HuggingFace cache into the Nix store.
If you already have models downloaded by transformers, diffusers, or
huggingface-cli, this avoids re-downloading files that are already on disk.
Use nix-hug scan to see what's available before importing.
The imported store path has the same layout as nix-hug fetch, so the output
can be used with buildCache and nix build.
When the Hub cannot describe the revision without a token (a private or gated
repo, or no network), import prints a derivation pinned to the imported
copy's hash instead of a fetchModel call. It evaluates without --impure,
needs no token, and works with buildCache, but it builds only where that copy
is in the store (or a binary cache serves it).
$ nix-hug import <url> [options]Options: --ref REF matches a specific revision; the filter options
(--include, --exclude, --file) are the same as fetch.
$ nix-hug import openai-community/gpt2
$ nix-hug import openai-community/gpt2 --include '*.safetensors'Imports every model and dataset found in the local HuggingFace cache into
the Nix store -- import for the whole cache in one pass.
$ nix-hug import-all # confirms before importing
$ nix-hug import-all --yes # no confirmation promptLists all models and datasets in the local HuggingFace cache. Useful for
discovering what's available before running import.
$ nix-hug scanShows each cached repository with its type, revision, size, file count, whether it's already in the Nix store, and any ref labels.
The library is available as nix-hug.lib.${system} from the flake output.
One function per Hub namespace. All three take the same arguments and return a derivation.
nix-hug-lib.fetchModel {
repoId = "stas/tiny-random-llama-2";
rev = "3579d71fd57e04f5a364d824d3a2ec3e913dbb67";
fileTreeHash = "sha256-mD+VYvxsLFH7+jiumTZYcE3f3kpMKeimaR0eElkT7FI=";
gitRepoHash = "sha256-SrdDsqK7grmWiB0nH4q78jUyGTta3ZX8UXuZCEhPwOw=";
}
nix-hug-lib.fetchDataset {
repoId = "rajpurkar/squad";
rev = "abc123...";
filters = file: pkgs.lib.hasSuffix ".json" file.path;
fileTreeHash = "sha256-...";
gitRepoHash = "sha256-...";
}
nix-hug-lib.fetchSpace {
repoId = "julien-c/hello-world";
rev = "4884451c8783f0eb1416903f79b643c756aaaf9a";
fileTreeHash = "sha256-byTXe33x1uGbldDjiZOnRoE1vyh7u31YnlqCjpxbI3I=";
gitRepoHash = "sha256-BSuQDGU/jdMkfBmEZV9Sn523N27+0OzuxRBdEjfvdXQ=";
}Parameters:
repoId(required): repository identifier (see URL Formats). Also accepted asurl.rev: git commit hash (40 characters). Exactly one ofrevortagis required.tag: a named ref resolved through the API instead of a commit hash; requiresrepoInfoHash, whose response changes on its own. Preferrev.fileTreeHash(required): SHA256 hash of the HF API file tree responsefilters(optional): predicatefile: bool, or a filter object withinclude,exclude, orfilesfileTree(optional): pre-fetched tree endpoint response, as a Nix value. It makes evaluation need no network at all -- see Offline evaluationgitRepoHash(required): hash for the non-LFS git checkout, which is a fixed-output derivation.nix-hug fetchalways emits itrepoType(optional):"model","dataset"or"space", matching the nixpkgs vocabulary. Each function already presets it, so you rarely pass it.repoInfoHash(optional): SHA256 of the revision API response; only needed to resolve atag
Predicates receive each LFS file as an attrset containing path and lfs.oid:
filters = file: pkgs.lib.hasSuffix ".safetensors" file.path;The attrset form remains useful for generated expressions and JSON-backed configuration:
filters.include = [ ".*\\.safetensors" ];
filters.exclude = [ "original/.*" ];
filters.files = [ "model.safetensors" ];include and exclude use builtins.match, whose regular expressions match
the entire path. For example, "safetensors" does not match
"model.safetensors"; use ".*\\.safetensors". A literal dot is \. in the
regular expression and therefore \\. inside a Nix string. Only one of
include, exclude, or files is applied; prefer a predicate for combined or
more complex conditions.
gitRepoHash is always required, so the only eval-time fetch left is the file
tree; supply fileTree and evaluation performs no network access at all.
nix-hug fetch --vendor DIR writes the tree and emits the matching expression.
nix-hug-lib.fetchModel {
repoId = "stas/tiny-random-llama-2";
rev = "3579d71fd57e04f5a364d824d3a2ec3e913dbb67";
fileTree = builtins.fromJSON (builtins.readFile ./trees/stas--tiny-random-llama-2.json);
fileTreeHash = "sha256-mD+VYvxsLFH7+jiumTZYcE3f3kpMKeimaR0eElkT7FI=";
gitRepoHash = "sha256-SrdDsqK7grmWiB0nH4q78jUyGTta3ZX8UXuZCEhPwOw=";
}We suggest you keep the JSON beside the Nix file that reads it. CI asserts the
property by evaluating a vendored expression whose hashes are deliberately
wrong: it reaches a derivation instead of failing, which it could only do
without fetching. --offline does not prove this on its own, since
builtins.fetchurl ignores it.
Vendoring also decides how a stale hash is reported. The vendored tree goes
through pkgs.fetchurl, so a mismatch prints SRI (sha256-mD+VYvxs...), which
is what determinate-nixd fix hashes and similar auto-updaters expect. Without
fileTree the tree is fetched by builtins.fetchurl, which reports Nix's
legacy base32 (sha256:0lpc2dci...) regardless of the format you supplied.
Nothing is an import-from-derivation. Every combination below evaluates with
allow-import-from-derivation = false, because builtins.fetchurl is an
eval-time builtin, not a read of a built derivation. What varies is eval-time
network, decided by two independent choices: whether you pass fileTree, and
whether you pass tag.
rev |
tag |
fileTree |
fetched during evaluation |
|---|---|---|---|
| yes | -- | yes | nothing |
| yes | -- | no | file tree |
| yes | yes | yes | revision API |
| yes | yes | no | file tree + revision API |
| -- | yes | either | revision API, + file tree if unvendored |
Two consequences are easy to miss.
Vendoring combines with tag. fileTree removes the need for a tree
fetch. Adding tag causes a network request to be needed to validate the tag
is present and it matches the fileTree hash. A tagged pin costs one eval-time
round-trip per model, and for that cost you get a check that upstream has not
been updated.
Vendoring can cost a few seconds due to some local evaluation time
requirements, even on a warm store causes a perf hit. Even on a fast CPU with
a fast connection, it easily costs more than a builtins.fetchurl that hits
the cache. Vendoring helps for offline evaluation, reproducibility and SRI
error reporting. On a cold store the ordering reverses, and it reverses further
once an evaluation imports dozens of models, because those fetches serialise.
Passing fileTree changes the derivation but not the download. Weights and
checkout are identical either way and the vendored form adds exactly one, the
nix-hug-filetree.json the build copies in. Switching a pin to or from
vendored re-links the assembly once and downloads nothing.
The same holds for adding tag to an existing rev: all fixed-output
derivations are unchanged and only the assembly moves, because the fetched
revision JSON is embedded in the output. A tag with no repoInfoHash cannot
be checked at all -- it is inert and warns rather than failing.
filters need the file list during evaluation, so they require fileTree, or
the tree fetch that stands in for it. Aggregate mode -- fetchFromHuggingFace
with no lfsFiles -- has no file list and so rejects filters, in exchange
for fetching the whole repository as one derivation with nothing evaluated.
nix-hug.lib.${system}.fetchFromHuggingFace extends nixpkgs'
fetchFromHuggingFace. Calls
without lfsFiles delegate unchanged to the upstream aggregate fetchgit -- a
check asserts the two produce the same derivation. Calls with lfsFiles use
nix-hug's split mode: hash is required and addresses the Git checkout with
LFS pointers, while each selected LFS blob is fetched by its own OID. Split
mode never fetches at evaluation time.
nix-hug.lib.${system}.fetchFromHuggingFace {
repoId = "openai/gpt-oss-120b";
rev = "b5c939de8f754692c1647ca79fbf85e8c1e70f8a";
backend = "lfs";
hash = "sha256-..."; # Git checkout with LFS pointers
lfsFiles = builtins.fromJSON (builtins.readFile ./gpt-oss-120b-lfs.json);
filters = file: pkgs.lib.hasPrefix "original/" file.path;
}fetchModel, fetchDataset, and fetchSpace delegate to this same extended
fetcher. Changing filters therefore needs no new hashes, widening a filter
downloads only newly selected blobs, and repositories sharing an OID share its
store path. For a whole repository under one hash, omit lfsFiles and it uses
upstream directly.
There is deliberately no overlay: shadowing pkgs.fetchFromHuggingFace would
silently change any package that later adopts it upstream, and calling this
export gives an identical derivation.
gitRepoHash is now required, and it is the only breaking change -- hence the
major bump. The non-LFS checkout is a fixed-output derivation, so nothing is
fetched at evaluation time. In 5.1 a missing gitRepoHash silently fell back to
an eval-time builtins.fetchGit; now it is an error that names the repository.
fetchModel, fetchDataset and url stay and are not deprecated; repoId,
repoType, tag, fetchSpace and fetchFromHuggingFace are additions to
align with fetchFromHuggingFace semantics; derivationHash is accepted but
ignored.
For one expression, re-run nix-hug fetch <repoId> --ref <rev> and copy the
gitRepoHash line it prints. For many, discovering the hash directly is a
pointer-only clone; no re-downloads will be necessary.
pkgs.fetchgit {
url = "https://huggingface.co/<repoId>.git";
rev = "<rev>";
fetchLFS = false;
hash = pkgs.lib.fakeHash;
}Build it and read the got: line. With more than a handful of pins, keep the
hashes in one JSON file beside the expressions and inject them by name, the way
fileTree is handled in Offline evaluation:
let
gitRepoHashes = builtins.fromJSON (builtins.readFile ./githashes.json);
in
builtins.mapAttrs (
name: def: nix-hug-lib.fetchModel (def // { gitRepoHash = gitRepoHashes.${name}; })
) modelDefsUpgrading moves the assembly derivation paths, so each cache re-links once. It does not re-download weights: LFS blobs are content-addressed by their OID, so their store paths are unchanged.
Combines fetched models and datasets into a HuggingFace Hub-compatible cache directory using symlinks (no data duplication).
nix-hug-lib.buildCache {
models = [ my-model another-model ];
datasets = [ my-dataset ];
}Use the result as HF_HUB_CACHE:
$ export HF_HUB_CACHE=/nix/store/...-hf-hub-cache
$ export TRANSFORMERS_OFFLINE=1
$ python your_script.pyModels:
mistralai/Mistral-7B-Instruct-v0.3hf:mistralai/Mistral-7B-Instruct-v0.3https://huggingface.co/mistralai/Mistral-7B-Instruct-v0.3
Datasets:
rajpurkar/squadhf-datasets:rajpurkar/squaddatasets/rajpurkar/squadhttps://huggingface.co/datasets/rajpurkar/squad
Spaces:
hf-spaces:julien-c/hello-worldspaces/julien-c/hello-worldhttps://huggingface.co/spaces/julien-c/hello-world
A prefixed path is taken at its word. A bare org/repo is probed against the
Hugging Face API in order -- dataset, model, space -- so a model and a space
sharing a name resolve to the model; prefix it to get the other one.
Plain git remotes with LFS weights also work:
git+https://codeberg.org/org/modelgit+ssh://git@codeberg.org/org/model
fetch accepts --lfs-url URL when the LFS server differs from the git
remote. These map to the library's fetchGitLFS function, which fetches LFS
blobs with pkgs.fetchurl at build time, as fetchModel does.
fetchGitLFS requires both gitRepoHash and lfsFiles: the checkout is a
pkgs.fetchgit fixed-output derivation, and reading it for pointers would make
evaluation import-from-derivation. nix-hug fetch discovers both and emits them
-- inline by default, or as a vendored JSON file under --vendor.
lib/default.nix now takes what it uses and arguments can be overriden:
{
pkgs ? null,
lib ? pkgs.lib,
fetchurl ? pkgs.fetchurl,
fetchgit ? pkgs.fetchgit,
runCommand ? pkgs.runCommand,
writeText ? pkgs.writeText,
linkFarm ? pkgs.linkFarm,
upstreamFetchFromHuggingFace ? if pkgs == null then null
else pkgs.fetchFromHuggingFace or null
}:import ./lib { inherit pkgs; } still works, pkgs.callPackage ./lib { }
works, and passing the original six by hand still supports split mode. Pass
upstreamFetchFromHuggingFace as well to expose aggregate delegation through
the narrow interface. Everything eval-time is spelled
builtins.fetchurl/builtins.fetchGit at the call site; the bare names are
the nixpkgs builders, which run at build time.
lib/fetch-from-hugging-face.nix has no eval-time fetch at all. A check
asserts the two call styles produce the same derivation.
nix-hug.lib.${system} is built from nix-hug's own nixpkgs. To avoid a second
nixpkgs in your closure:
inputs.nix-hug.inputs.nixpkgs.follows = "nixpkgs";$ nix develop
$ ./cli/nix-hug --helpRun the tests:
$ nix flake checkThis software is provided free under the MIT License.