Repository: sharkdp/fd (Rust)

Issue:
[BUG] --print0 and --exec does not include null byte
### Checks

- [x] I have read the troubleshooting section and still think this is a bug.

### Describe the bug you encountered:

## Setup

```
# fd --version 
fd 10.3.0

# mkdir /tmp/fd_test

# cd /tmp/fd_test/

# touch 1 2 3

# ls
1  2  3
```

## print0 / exec (the bug)

- The null byte (`00`) is not included. It should be.

```
# fd --print0 --exec echo -n ';' . -- /tmp/fd_test | hexdump -C
00000000  2f 74 6d 70 2f 66 64 5f  74 65 73 74 2f 32 2f 74  |/tmp/fd_test/2/t|
00000010  6d 70 2f 66 64 5f 74 65  73 74 2f 31 2f 74 6d 70  |mp/fd_test/1/tmp|
00000020  2f 66 64 5f 74 65 73 74  2f 33                    |/fd_test/3|
```

## print0 / no exec

- `00` is null byte.
- This is the correct output.

```
# fd --print0 . -- /tmp/fd_test | hexdump -C
00000000  2f 74 6d 70 2f 66 64 5f  74 65 73 74 2f 31 00 2f  |/tmp/fd_test/1./|
00000010  74 6d 70 2f 66 64 5f 74  65 73 74 2f 32 00 2f 74  |tmp/fd_test/2./t|
00000020  6d 70 2f 66 64 5f 74 65  73 74 2f 33 00           |mp/fd_test/3.|
0000002d
```

## no print0 / no exec

- `0a` is new line.
- This is the correct output.

```
# fd . -- /tmp/fd_test | hexdump -C
00000000  2f 74 6d 70 2f 66 64 5f  74 65 73 74 2f 31 0a 2f  |/tmp/fd_test/1./|
00000010  74 6d 70 2f 66 64 5f 74  65 73 74 2f 32 0a 2f 74  |tmp/fd_test/2./t|
00000020  6d 70 2f 66 64 5f 74 65  73 74 2f 33 0a           |mp/fd_test/3.|
0000002d
```


### Describe what you expected to happen:

_No response_

### What version of `fd` are you using?

fd 10.3.0

### Which operating system / distribution are you on?

```shell
# uname -a
Linux REDACTED 6.15.7-arch1-1 #1 SMP PREEMPT_DYNAMIC Thu, 17 Jul 2025 21:05:29 +0000 x86_64 GNU/Linux
```


Discussion:
I think this is (mostly) intentional.

--print0 is intended for use with printing the results, not with --exec or --exec-batch.  With the exec options, the arguments are passed as individual arguments so using a delimeter such as nul isn't (usually?) necessary.

But perhaps, the --print0 option should be marked as conflicting with the exec options. 

Firthermore, the way that command line arguments work, at least on Unix-like systems, you can't include a nul byte in an argument, because that is used for marking the end of the argument.

What is your use case for wanting exec to include the nul byte?

FWIW, you could get your desired output with `printf '%s\0'` instead of `echo -n`.

Thank you. I considered this may have been intentional, however, I didn't find any documentation to that effect. If it is the intention, at the very least some documentation, flag exclusion, or the existence of this bug report may help others in the future.

As you suggested it is possible to add the null byte for certain commands but it's not ubiquitous. This can also be accomplished by using `bash -c`, running the command, and printing the null byte (`bash -c 'stat /tmp/fd_test/3 ; printf \0'`). In this case there is additional overhead from calling `bash` and `printf` command. It is also less visually elegant. I am not certain the call to `bash -c` adds much overhead.

I often call `fd` in Python. I thought this would be useful for slurping up output with new line characters and leveraging the `--threads` flag to bypass Python's GIL.

```py
#!/usr/bin/env python

from subprocess import Popen, PIPE
import subprocess, json

cmd = ['fd', '.', '/tmp/fd_test', '--threads', '8', '--exec', 'bash', '-c', 'stat {} ; printf "\\0"']

# If the suggesting here were implemented it would become:
# cmd = ['fd', '.', '/tmp/fd_test', '--threads', '8', '--print0', '--exec', 'stat']

process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
process.wait()

buffer = bytearray(b'')
result = []
while out := process.stdout.read(1024):
    buffer += out
    while b'\x00' in buffer:
        index = buffer.index(b'\x00')
        res, buffer = (buffer[: index + 1], buffer[index + 1 :])
        result.append(res.decode().split('\n'))

print(json.dumps(result, indent=2))
```

```
[
  [
    "  File: /tmp/fd_test/3",
    "  Size: 6         \tBlocks: 8          IO Block: 4096   regular file",
    "Device: 0,36\tInode: 444         Links: 1",
    "Access: (0600/-rw-------)  Uid: ( 1000/       user)   Gid: ( 1000/       user)",
    "Access: 2025-09-21 15:43:20.195911129 -0400",
    "Modify: 2025-09-21 14:46:54.517516276 -0400",
    "Change: 2025-09-21 14:46:54.517516276 -0400",
    " Birth: 2025-09-21 11:50:57.547830262 -0400",
    "\u0000"
  ],
  [
    "  File: /tmp/fd_test/1",
    "  Size: 6         \tBlocks: 8          IO Block: 4096   regular file",
    "Device: 0,36\tInode: 442         Links: 1",
    "Access: (0600/-rw-------)  Uid: ( 1000/       user)   Gid: ( 1000/       user)",
    "Access: 2025-09-21 15:43:20.175911020 -0400",
    "Modify: 2025-09-21 14:47:12.163608234 -0400",
    "Change: 2025-09-21 14:47:12.163608234 -0400",
    " Birth: 2025-09-21 11:50:57.546830257 -0400",
    "\u0000"
  ],
  [
    "  File: /tmp/fd_test/2",
    "  Size: 6         \tBlocks: 8          IO Block: 4096   regular file",
    "Device: 0,36\tInode: 443         Links: 1",
    "Access: (0600/-rw-------)  Uid: ( 1000/       user)   Gid: ( 1000/       user)",
    "Access: 2025-09-21 15:43:20.175911020 -0400",
    "Modify: 2025-09-21 14:46:54.505516213 -0400",
    "Change: 2025-09-21 14:46:54.505516213 -0400",
    " Birth: 2025-09-21 11:50:57.547830262 -0400",
    "\u0000"
  ]
]
```

A possibility I had in mind is loading metadata about media files with `ffprobe`. `ffprobe` takes 0.3 seconds per file on my system so parallelizing is prefered. `fd -t f -I '.mp3$' --threads 8 --exec ffprobe -v quiet -show_streams -print_format json -show_format ';'`

So my argument for the implementation would be:

-   Makes the command line a bit prettier.
-   It may be marginally more optimized.

I certainly won't die on this hill. If others don't find the utility in it, I will proceed using `bash -c`.

Would https://github.com/sharkdp/fd/issues/1765 meet your needs. 

When I have some more time, I'm planning on looking into that and making a json output format that includes the `stat` data in the json output, if it isn't too complicated.

But I may have also misunderstood. Are you wanting the `\0' to be between the outputs of the different commands run by `--exec`? That is probably reasonable to do. 
My preference would be for any arbitrary command run by `--exec`, the output be separated by `\0`. My request is not specific to `stat`/`ls` (I should have used a different command for my example). 

So if I ran  `fd . /tmp/fd_test --print0 --exec head`, the contents of each multi line file would be separated by `\0`. For example
```
h\ni\nj\n\0d\ne\nf\n\0a\nb\nc\n\0
```
Viewed with hexdump (`00` is null byte. `0a` is new line)
```
00000000  61 0a 62 0a 63 0a 00 64  0a 65 0a 66 0a 00 68 0a  |a.b.c..d.e.f..h.|
00000010  69 0a 6a 0a 00                                    |i.j..|
00000015
```
Since `--print0` doesn't do anything when combined with `--exec` it would provide additional functionality without breaking existing usage.

Thanks!



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d38415a51..18e3f0459 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,9 @@
 
 ## Bugfixes
 
+- `--print0` combined with `--exec` will now print a `\0` between the output of each entry. Note that if there are multiple instances
+  of `--exec`, the `\0` will be between each _set_ of commands, _not_ between each individual command run. Fixes #1797.
+
 
 ## Changes
 
diff --git a/doc/fd.1 b/doc/fd.1
index 4a6aa0ece..738748176 100644
--- a/doc/fd.1
+++ b/doc/fd.1
@@ -449,6 +449,9 @@ option.
 
 If no placeholder is present, an implicit "{}" at the end is assumed.
 
+If --print0 is also given, then a null character (\\0) will be printed between the output for each found entry.
+This allows another program to easily distinguish the output for each file if the command(s) produce multiple lines.
+
 Examples:
 
   - find all *.zip files and unzip them:
@@ -462,6 +465,10 @@ Examples:
   - Convert all *.jpg files to *.png files:
 
         fd -e jpg -x convert {} {.}.png
+
+  - Run stat for each *.txt file, separated by null characters
+
+        fd -0 -e txt -x stat
 .RE
 .TP
 .BI "\-X, \-\-exec-batch " command
diff --git a/src/exec/command.rs b/src/exec/command.rs
index 0692166bc..89522b029 100644
--- a/src/exec/command.rs
+++ b/src/exec/command.rs
@@ -1,6 +1,5 @@
 use std::io;
 use std::io::Write;
-use std::sync::Mutex;
 
 use argmax::Command;
 
@@ -11,15 +10,15 @@ struct Outputs {
     stdout: Vec<u8>,
     stderr: Vec<u8>,
 }
-struct OutputBuffer<'a> {
-    output_permission: &'a Mutex<()>,
+pub struct OutputBuffer {
+    null_separator: bool,
     outputs: Vec<Outputs>,
 }
 
-impl<'a> OutputBuffer<'a> {
-    fn new(output_permission: &'a Mutex<()>) -> Self {
+impl OutputBuffer {
+    pub fn new(null_separator: bool) -> Self {
         Self {
-            output_permission,
+            null_separator,
             outputs: Vec::new(),
         }
     }
@@ -29,17 +28,19 @@ impl<'a> OutputBuffer<'a> {
     }
 
     fn write(self) {
-        // avoid taking the lock if there is nothing to do
-        if self.outputs.is_empty() {
+        // Avoid taking the lock if there is nothing to do.
+        // If null_separator is true, then we still need to write the
+        // null separator, because the output may have been written directly
+        // to stdout
+        if self.outputs.is_empty() && !self.null_separator {
             return;
         }
-        // While this lock is active, this thread will be the only thread allowed
-        // to write its outputs.
-        let _lock = self.output_permission.lock().unwrap();
 
         let stdout = io::stdout();
         let stderr = io::stderr();
 
+        // While we hold these locks, only this thread will be able
+        // to write its outputs.
         let mut stdout = stdout.lock();
         let mut stderr = stderr.lock();
 
@@ -47,16 +48,20 @@ impl<'a> OutputBuffer<'a> {
             let _ = stdout.write_all(&output.stdout);
             let _ = stderr.write_all(&output.stderr);
         }
+        if self.null_separator {
+            // If null_separator is enabled, then we should write a \0 at the end
+            // of the output for this entry
+            let _ = stdout.write_all(b"\0");
+        }
     }
 }
 
 /// Executes a command.
 pub fn execute_commands<I: Iterator<Item = io::Result<Command>>>(
     cmds: I,
-    out_perm: &Mutex<()>,
+    mut output_buffer: OutputBuffer,
     enable_output_buffering: bool,
 ) -> ExitCode {
-    let mut output_buffer = OutputBuffer::new(out_perm);
     for result in cmds {
         let mut cmd = match result {
             Ok(cmd) => cmd,
diff --git a/src/exec/job.rs b/src/exec/job.rs
index 4864d6dca..d4702c471 100644
--- a/src/exec/job.rs
+++ b/src/exec/job.rs
@@ -1,5 +1,3 @@
-use std::sync::Mutex;
-
 use crate::config::Config;
 use crate::error::print_error;
 use crate::exit_codes::{merge_exitcodes, ExitCode};
@@ -13,7 +11,6 @@ use super::CommandSet;
 pub fn job(
     results: impl IntoIterator<Item = WorkerResult>,
     cmd: &CommandSet,
-    out_perm: &Mutex<()>,
     config: &Config,
 ) -> ExitCode {
     // Output should be buffered when only running a single thread
@@ -37,7 +34,7 @@ pub fn job(
         let code = cmd.execute(
             dir_entry.stripped_path(config),
             config.path_separator.as_deref(),
-            out_perm,
+            config.null_separator,
             buffer_output,
         );
         ret = merge_exitcodes([ret, code]);
diff --git a/src/exec/mod.rs b/src/exec/mod.rs
index c964e6ed2..c50007707 100644
--- a/src/exec/mod.rs
+++ b/src/exec/mod.rs
@@ -6,11 +6,11 @@ use std::io;
 use std::iter;
 use std::path::{Path, PathBuf};
 use std::process::Stdio;
-use std::sync::Mutex;
 
 use anyhow::{bail, Result};
 use argmax::Command;
 
+use crate::exec::command::OutputBuffer;
 use crate::exit_codes::{merge_exitcodes, ExitCode};
 use crate::fmt::{FormatTemplate, Token};
 
@@ -80,14 +80,14 @@ impl CommandSet {
         &self,
         input: &Path,
         path_separator: Option<&str>,
-        out_perm: &Mutex<()>,
+        null_separator: bool,
         buffer_output: bool,
     ) -> ExitCode {
         let commands = self
             .commands
             .iter()
             .map(|c| c.generate(input, path_separator));
-        execute_commands(commands, out_perm, buffer_output)
+        execute_commands(commands, OutputBuffer::new(null_separator), buffer_output)
     }
 
     pub fn execute_batch<I>(&self, paths: I, limit: usize, path_separator: Option<&str>) -> ExitCode
diff --git a/src/walk.rs b/src/walk.rs
index 0991e1c73..a9a34c4dd 100644
--- a/src/walk.rs
+++ b/src/walk.rs
@@ -413,8 +413,6 @@ impl WorkerState {
             if cmd.in_batch_mode() {
                 exec::batch(rx.into_iter().flatten(), cmd, config)
             } else {
-                let out_perm = Mutex::new(());
-
                 thread::scope(|scope| {
                     // Each spawned job will store its thread handle in here.
                     let threads = config.threads;
@@ -423,8 +421,8 @@ impl WorkerState {
                         let rx = rx.clone();
 
                         // Spawn a job thread that will listen for and execute inputs.
-                        let handle = scope
-                            .spawn(|| exec::job(rx.into_iter().flatten(), cmd, &out_perm, config));
+                        let handle =
+                            scope.spawn(|| exec::job(rx.into_iter().flatten(), cmd, config));
 
                         // Push the handle of the spawned thread into the vector for later joining.
                         handles.push(handle);


Test patch:
diff --git a/tests/tests.rs b/tests/tests.rs
index 76da9fee3..6d477f662 100644
--- a/tests/tests.rs
+++ b/tests/tests.rs
@@ -1826,6 +1826,21 @@ fn test_exec_multi() {
     );
 }
 
+#[cfg(not(windows))]
+#[test]
+fn test_exec_nulls() {
+    let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
+    te.assert_output(
+        &["foo", "--print0", "--exec", "printf", "p=%s"],
+        "p=./a.fooNULL
+        p=./one/b.fooNULL
+        p=./one/two/C.Foo2NULL
+        p=./one/two/c.fooNULL
+        p=./one/two/three/d.fooNULL
+        p=./one/two/three/directory_fooNULL",
+    );
+}
+
 #[test]
 fn test_exec_batch() {
     let (te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES);