zt-exec: one ProcessExecutor class for running external programs from Java
ZeroTurnaround Process Executor
At a glance
- What is it?
- zt-exec is a small Apache-licensed library from ZeroTurnaround that wraps java.lang.ProcessBuilder and Apache Commons Exec into a single fluent ProcessExecutor class, with timeouts, exit code validation, SLF4J stream redirection and background execution. It runs on Java 8 and has one third party dependency.
- Who is it for?
- zt-exec suits a Java codebase that shells out regularly and has outgrown raw ProcessBuilder but does not want a dependency as large as Apache Commons Exec, and its single dependency on SLF4J means it drops into an existing logging setup without negotiation.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly Java, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One class that merges ProcessBuilder and Commons Exec
zt-exec exists because of duplication. The project was created to merge similar functionality of projects at ZeroTurnaround into a single codebase, and the goal was stated as being powerful but still simple to use. The mechanism for that is a single class, ProcessExecutor, which gives the user the functionality from both java.lang.ProcessBuilder and Apache Commons Exec.
That framing also explains the library's size. It is one class with a fluent builder, not a framework with an extension model. The whole surface is a chain: create the executor, state the command, state the options, execute.
new ProcessExecutor().command("java", "-version").execute();The minimum required Java version is 8, and the only third party dependency is SLF4J. That combination is the main practical argument for the library. A Java 8 baseline means it drops into legacy service code without a version negotiation, and one dependency on a logging facade you almost certainly already have means it does not drag in a logging implementation or a transitive tree.
The stated motivations are a list of improvements over the raw JRE options: improved handling of streams including reading, writing and redirecting stderr to stdout, improved handling of timeouts, improved checking of exit codes, an improved API with one liners for quite complex use cases and one liners to get process output into a String, access to the Process object, support for async processes returning a Future, improved logging with the SLF4J API, and support for multiple processes.
The README is candid about calling this yet another process library, with a YAPL acronym in the source. The point is not that the JDK is inadequate but that the ergonomics of ProcessBuilder for common cases are poor, and that the alternative library is a larger commitment.
Getting output back, and the exit code check that catches failures
The examples in the documentation read as a progression, and the first few establish the two things you do with nearly every external process: capture its output and check how it ended.
By default output is pumped to a NullOutputStream, so nothing accumulates in memory unless you ask for it. Capturing output as a UTF8 String is a matter of enabling read output and asking for the right accessor.
String output = new ProcessExecutor().command("java", "-version")
.readOutput(true).execute()
.outputUTF8();The exit code check is the part that saves the most boilerplate in real code. Setting exitValues declares which exit codes count as success, and a mismatch throws InvalidExitValueException rather than returning a code you have to remember to inspect.
try {
new ProcessExecutor().command("java", "-version")
.exitValues(3).execute();
}
catch (InvalidExitValueException e) {
System.out.println("Process exited with " + e.getExitValue());
}The exception carries the result, so the output is still available after the failure. The documentation shows the combined pattern where the output is captured on the happy path and recovered from the exception on the unhappy one, which is the shape you need when you want to log what the process printed before it failed.
String output;
boolean success = false;
try {
output = new ProcessExecutor().command("java", "-version")
.readOutput(true).exitValues(3)
.execute().outputUTF8();
success = true;
}
catch (InvalidExitValueException e) {
System.out.println("Process exited with " + e.getExitValue());
output = e.getResult().outputUTF8();
}Note that the exit code list is an allowlist. Anything not listed is treated as a failure, which is the safer default and the opposite of the common mistake of only checking for zero.
Timeouts destroy the process instead of leaking it
Timeouts are one of the listed motivations, and the example shows the behaviour that matters.
try {
new ProcessExecutor().command("java", "-version")
.timeout(60, TimeUnit.SECONDS).execute();
}
catch (TimeoutException e) {
// process is automatically destroyed
}The comment in the documentation is the feature. When the timeout fires, the process is destroyed automatically. Without that, a timed out external command leaves an orphaned process running, and in a long-lived service that is how you accumulate stray processes until the host runs out of resources.
The unit is explicit rather than implied, since the API takes a number and a TimeUnit, so there is no ambiguity about whether a bare number means seconds or milliseconds.
Two other lifecycle options sit alongside it. destroyOnExit registers the process for destruction when the JVM exits, which is the safety net for a case where the process is expected to outlive the request that started it. And for environment handling there are two forms, a single variable and a whole map.
new ProcessExecutor().command("java", "-version")
.environment("foo", "bar").execute();Map<String, String> env = ...;
new ProcessExecutor().command("java", "-version")
.environment(env).execute();The distinction between the two matters more than it looks. Setting one variable merges it into the inherited environment, while passing a map may replace the environment wholesale depending on how the implementation treats it. The documentation shows both signatures without stating the semantics, so if you depend on inheriting PATH or HOME, that behaviour is worth confirming against the source rather than assuming.
SLF4J redirection replaces hand rolled pump threads
Streaming a process's output into a logger is the use case that would otherwise need a thread per stream, and zt-exec handles it with a small family of helpers.
The simplest form names the logger explicitly and chooses the level.
new ProcessExecutor().command("java", "-version")
.redirectOutput(Slf4jStream.of(LoggerFactory.getLogger(getClass().getName() + ".MyProcess")).asInfo()).execute();There is a short form that derives the logger name from the string you give it.
new ProcessExecutor().command("java", "-version")
.redirectOutput(Slf4jStream.of("MyProcess").asInfo()).execute();And there is a caller form, which uses the class of the calling object as the logger name.
new ProcessExecutor().command("java", "-version")
.redirectOutput(Slf4jStream.ofCaller().asInfo()).execute();The redirect is not limited to the logger. Output can go to any OutputStream you supply, which is what you want when a subprocess feeds a file or a socket.
OutputStream out = ...;
new ProcessExecutor().command("java", "-version")
.redirectOutput(out).execute();And stderr can be redirected separately, which is a distinction that matters when you want failures logged at a different level from ordinary output.
String output = new ProcessExecutor().command("java", "-version")
.redirectError(Slf4jStream.of(getClass()).asInfo())
.readOutput(true).execute()
.outputUTF8();For handling output line by line while the process is still running, there are two forms. The modern one takes a lambda and is documented as Java 8 and later.
new ProcessExecutor().command("java", "-version")
.redirectOutput(line -> ...)
.execute();The older form uses an anonymous LogOutputStream subclass, documented for versions before Java 8.
new ProcessExecutor().command("java", "-version")
.redirectOutput(new LogOutputStream() {
@Override
protected void processLine(String line) {
...
}
})
.execute();That both forms are documented is a good sign about how the library handles its own compatibility promise, and the anonymous class version is what a Java 7 codebase would still compile.
Background execution returns a Future instead of blocking
The last capability in the example set is asynchronous execution, which the README lists under the API improvements as support for async processes returning a Future.
Future<ProcessResult> future = new ProcessExecutor()
.command("java", "-version")
.start().getFuture();
// do some stuff
future.get(60, TimeUnit.SECONDS);The structure is worth understanding. start() launches the process and returns immediately, and getFuture() hands back a Future for the eventual ProcessResult. Your own timeout applies to the wait, not to the process launch, so you can bound how long you are willing to block on the result.
The same pattern with output capture is the same shape with one more call.
Future<ProcessResult> future = new ProcessExecutor()
.command("java", "-version")
.readOutput(true)
.start().getFuture();
// do some stuff
String output = future.get(60, TimeUnit.SECONDS).outputUTF8();The design point is that the Future carries the same ProcessResult type as the synchronous path, so the output accessors work identically once the result arrives. There is no separate asynchronous API to learn.
What the library explicitly does not do is kill processes. The documentation asks the question directly and answers it with no, pointing to a separate project, zt-process-killer, for that. That boundary is worth respecting in a plan, because a process you start in the background and then fail to consume is your problem to manage.
Build, licensing and what a single release tells you
The artefact coordinates are the whole installation story, and the project is on Maven Central under org.zeroturnaround.
<dependency>
<groupId>org.zeroturnaround</groupId>
<artifactId>zt-exec</artifactId>
<version>1.13.0</version>
</dependency>implementation("org.zeroturnaround:zt-exec:1.13.0")The build itself uses Gradle with Kotlin DSL, with build.gradle.kts and settings.gradle.kts at the root and the wrapper scripts alongside, and there is a GitHub Actions build workflow. The build section of the documentation is truncated in the visible text, so the exact commands are not given there, and the gradlew wrapper is the obvious route.
The licence field on the repository says Apache-2.0, and the root carries both a LICENSE file and a NOTICE.txt. A separate notice file is where attribution details for bundled files usually live, so it is worth reading if you redistribute the library inside a product. This is not legal advice.
Release information is thin. There is a single tagged release, v1.13.0, dated 2026-07-10, and a CHANGELOG.md at the root. A one-entry release list is either a recent tagging policy change or an artifact of how releases are published, and it means you cannot use the tag history to judge how the library has evolved.
What you can judge from is the dependency position and the Java floor. Java 8 as a minimum, one dependency, and a single fluent class is a profile that changes rarely, and a project with that profile tends to age quietly. The last push was on 2026-10-19, so the tree is being worked on regardless.
Editorial conclusion
zt-exec suits a Java codebase that shells out regularly and has outgrown raw ProcessBuilder but does not want a dependency as large as Apache Commons Exec, and its single dependency on SLF4J means it drops into an existing logging setup without negotiation. It does not suit a project that needs to kill process trees, because the library explicitly does not kill processes and points at zt-process-killer instead, and it does not suit a codebase already standardised on Commons Exec. Before adopting it, check your Java version floor against the Java 8 minimum, since the lambda based line-by-line output handling is documented as Java 8 or later with an anonymous class fallback for earlier versions, and read the NOTICE.txt file, since the repository ships a separate notice file alongside the Apache-2.0 licence.
Frequently asked questions
What is zt-exec and what does it replace?
zt-exec is a library from ZeroTurnaround that merges similar functionality from projects at ZeroTurnaround into one codebase. Using a single ProcessExecutor class it provides functionality from both java.lang.ProcessBuilder and Apache Commons Exec, and it was created as yet another process library, with the source calling it YAPL.
How do I add zt-exec to my build?
The artefact is on Maven Central under org.zeroturnaround. In Maven add the dependency with groupId org.zeroturnaround, artifactId zt-exec and version 1.13.0, and in Gradle use implementation("org.zeroturnaround:zt-exec:1.13.0").
Can zt-exec kill processes?
No. The documentation states plainly that it does not kill processes and points to zt-process-killer for that. Timeouts do destroy the process automatically, but there is no general process killing facility in this library.
How does zt-exec handle timeouts?
You set a timeout with a number and a TimeUnit, and if it elapses a TimeoutException is thrown. The documentation notes that the process is automatically destroyed in that case, rather than being left running.
What are the dependencies and Java version requirement for zt-exec?
The minimal required Java version is 8, and the only third party dependency is SLF4J. Line by line output handling is documented with a lambda form for Java 8 and later, and an anonymous LogOutputStream subclass for earlier versions.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/zeroturnaround-zt-exec)Was this useful?