diff --git a/Makefile b/Makefile
index b7f0e79db2f65757e2d2c678ac12103628460dd1..dd259e5cdf7c99a74d73489f90ccbf7d33d86ee9 100644
--- a/Makefile
+++ b/Makefile
@@ -12,6 +12,7 @@ makefiles = \
   src/resolve-system-dependencies/local.mk \
   scripts/local.mk \
   misc/bash/local.mk \
+  misc/fish/local.mk \
   misc/zsh/local.mk \
   misc/systemd/local.mk \
   misc/launchd/local.mk \
diff --git a/misc/fish/completion.fish b/misc/fish/completion.fish
new file mode 100644
index 0000000000000000000000000000000000000000..bedbefaf8c90c4532ec09dea94707ac12a79f094
--- /dev/null
+++ b/misc/fish/completion.fish
@@ -0,0 +1,37 @@
+function _nix_complete
+  # Get the current command up to a cursor.
+  # - Behaves correctly even with pipes and nested in commands like env.
+  # - TODO: Returns the command verbatim (does not interpolate variables).
+  #   That might not be optimal for arguments like -f.
+  set -l nix_args (commandline --current-process --tokenize --cut-at-cursor)
+  # --cut-at-cursor with --tokenize removes the current token so we need to add it separately.
+  # https://github.com/fish-shell/fish-shell/issues/7375
+  # Can be an empty string.
+  set -l current_token (commandline --current-token --cut-at-cursor)
+
+  # Nix wants the index of the argv item to complete but the $nix_args variable
+  # also contains the program name (argv[0]) so we would need to subtract 1.
+  # But the variable also misses the current token so it cancels out.
+  set -l nix_arg_to_complete (count $nix_args)
+
+  env NIX_GET_COMPLETIONS=$nix_arg_to_complete $nix_args $current_token
+end
+
+function _nix_accepts_files
+  set -l response (_nix_complete)
+  # First line is either filenames or no-filenames.
+  test $response[1] = 'filenames'
+end
+
+function _nix
+  set -l response (_nix_complete)
+  # Skip the first line since it handled by _nix_accepts_files.
+  # Tail lines each contain a command followed by a tab character and, optionally, a description.
+  # This is also the format fish expects.
+  string collect -- $response[2..-1]
+end
+
+# Disable file path completion if paths do not belong in the current context.
+complete --command nix --condition 'not _nix_accepts_files' --no-files
+
+complete --command nix --arguments '(_nix)'
diff --git a/misc/fish/local.mk b/misc/fish/local.mk
new file mode 100644
index 0000000000000000000000000000000000000000..ece899fc3cb2c50e26e10c23b0289f483ae41709
--- /dev/null
+++ b/misc/fish/local.mk
@@ -0,0 +1 @@
+$(eval $(call install-file-as, $(d)/completion.fish, $(datarootdir)/fish/vendor_completions.d/nix.fish, 0644))