Maven JAR Plugin
last modified November 13, 2026
In this article we work with the Maven JAR Plugin. We look at what a JAR
built by Maven contains, add entries to the manifest, build a thin
application JAR with a lib directory, create additional JARs
with classifiers, and package the test classes. The examples use
Maven 3.10, JAR plugin 3.5.1, and Java 25.
The JAR plugin creates the main artifact of projects with
jar packaging. Its jar goal is bound to the
package phase by default, so every mvn package
runs it. The plugin packs the content of target/classes: the
compiled classes and the resources copied from
src/main/resources. It does not include dependencies; for
JARs with dependencies see the Maven Shade
Plugin and the Maven executable
JAR articles.
The project
Our example counts words. It has a small API package, an implementation that uses Commons Lang, a command line entry point, and a benchmark that is useful during development but should not be shipped.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>wordcount</artifactId>
<version>1.0.0</version>
<name>Word count</name>
<properties>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.21.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.1</version>
</plugin>
</plugins>
</build>
</project>
The JAR plugin is part of the default lifecycle and works without any declaration. We pin its version so that the build does not depend on the default version of the Maven release in use.
package com.example.wordcount.api;
import java.util.Map;
public interface WordCounter {
Map<String, Integer> count(String text);
}
The api package holds the interface.
package com.example.wordcount;
import com.example.wordcount.api.WordCounter;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import java.util.TreeMap;
public class SimpleWordCounter implements WordCounter {
@Override
public Map<String, Integer> count(String text) {
var counts = new TreeMap<String, Integer>();
for (var word : StringUtils.split(text.toLowerCase(), " ,.;:!?")) {
counts.merge(word, 1, Integer::sum);
}
return counts;
}
}
The implementation splits the text with StringUtils.split
from Commons Lang and counts the words in a sorted map.
package com.example.wordcount;
public class Main {
public static void main(String[] args) {
var pkg = Main.class.getPackage();
System.out.printf("%s %s%n", pkg.getImplementationTitle(),
pkg.getImplementationVersion());
var text = String.join(" ", args);
new SimpleWordCounter().count(text)
.forEach((word, n) -> System.out.printf("%-6s %d%n", word, n));
}
}
The entry point prints the title and version of its package, then the word counts of its arguments.
var pkg = Main.class.getPackage();
System.out.printf("%s %s%n", pkg.getImplementationTitle(),
pkg.getImplementationVersion());
The getImplementationTitle and
getImplementationVersion methods read the
Implementation-Title and Implementation-Version
attributes of the JAR manifest. They return null when the
attributes are missing.
package com.example.wordcount.dev;
import com.example.wordcount.SimpleWordCounter;
public class Benchmark {
public static void main(String[] args) {
var text = "the quick brown fox jumps over the lazy dog ".repeat(100_000);
long start = System.nanoTime();
new SimpleWordCounter().count(text);
System.out.printf("%d ms%n", (System.nanoTime() - start) / 1_000_000);
}
}
The benchmark lives in the dev package. The project also has
a JUnit test of SimpleWordCounter and a
stopwords.txt resource.
The default JAR
We build the project and look inside the JAR.
The JAR contains the classes, the resource, and a
META-INF/maven directory with a copy of the POM and a
pom.properties file with the coordinates. Tools use these
files to identify a JAR found on a classpath. The manifest has only basic
attributes; JAR plugin 3.5 records the Java version used for the build.
All entries are dated 1980-02-01, because Maven 3.10 builds reproducible
JARs by default. The test class is not included.
$ mvn package
--- jar:3.5.1:jar (default-jar) @ wordcount ---
Building jar: /home/user/project/wordcount/target/wordcount-1.0.0.jar
BUILD SUCCESS
$ unzip -l target/wordcount-1.0.0.jar
Archive: target/wordcount-1.0.0.jar
Length Date Time Name
--------- ---------- ----- ----
0 1980-02-01 00:00 META-INF/
99 1980-02-01 00:00 META-INF/MANIFEST.MF
0 1980-02-01 00:00 com/
0 1980-02-01 00:00 com/example/
0 1980-02-01 00:00 com/example/wordcount/
0 1980-02-01 00:00 com/example/wordcount/api/
0 1980-02-01 00:00 com/example/wordcount/dev/
0 1980-02-01 00:00 META-INF/maven/
0 1980-02-01 00:00 META-INF/maven/com.example/
0 1980-02-01 00:00 META-INF/maven/com.example/wordcount/
1975 1980-02-01 00:00 com/example/wordcount/Main.class
1961 1980-02-01 00:00 com/example/wordcount/SimpleWordCounter.class
280 1980-02-01 00:00 com/example/wordcount/api/WordCounter.class
1056 1980-02-01 00:00 com/example/wordcount/dev/Benchmark.class
9 1980-02-01 00:00 stopwords.txt
1240 1980-02-01 00:00 META-INF/maven/com.example/wordcount/pom.xml
55 1980-02-01 00:00 META-INF/maven/com.example/wordcount/pom.properties
--------- -------
6675 17 files
$ unzip -p target/wordcount-1.0.0.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 25
Build-Jdk-Spec: 25
$ unzip -p target/wordcount-1.0.0.jar META-INF/maven/com.example/wordcount/pom.properties
artifactId=wordcount
groupId=com.example
version=1.0.0
Manifest entries
The archive element configures the manifest. We add a main
class, the implementation entries, and two custom attributes, and we
exclude the dev package.
<configuration>
<archive>
<manifest>
<mainClass>com.example.wordcount.Main</mainClass>
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
</manifest>
<manifestEntries>
<Automatic-Module-Name>com.example.wordcount</Automatic-Module-Name>
<Built-By>zetcode</Built-By>
</manifestEntries>
</archive>
<excludes>
<exclude>**/dev/**</exclude>
</excludes>
</configuration>
The mainClass element writes the Main-Class
attribute used by java -jar. With
addDefaultImplementationEntries, the plugin writes the
Implementation-Title and Implementation-Version
attributes from the project name and version.
<manifestEntries>
<Automatic-Module-Name>com.example.wordcount</Automatic-Module-Name>
<Built-By>zetcode</Built-By>
</manifestEntries>
The manifestEntries element adds arbitrary attributes; the
element name is the attribute name. Automatic-Module-Name
gives a stable module name to a JAR without
module-info.java when it is put on the module path.
<excludes>
<exclude>**/dev/**</exclude>
</excludes>
The excludes patterns are relative to
target/classes. The classes in the dev package
are still compiled and can be run from the IDE, but they do not get into
the JAR.
The benchmark is gone and the manifest has the new attributes. The JAR
starts and prints the version from the manifest, but fails on the first
use of Commons Lang: java -jar ignores the
CLASSPATH variable and the -cp option, so the
dependencies must be listed in the manifest.
$ mvn clean package
BUILD SUCCESS
$ unzip -l target/wordcount-1.0.0.jar | grep class
1975 1980-02-01 00:00 com/example/wordcount/Main.class
1961 1980-02-01 00:00 com/example/wordcount/SimpleWordCounter.class
280 1980-02-01 00:00 com/example/wordcount/api/WordCounter.class
$ unzip -p target/wordcount-1.0.0.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.5.1
Java-Version: 25
Build-Jdk-Spec: 25
Implementation-Title: Word count
Implementation-Version: 1.0.0
Main-Class: com.example.wordcount.Main
Automatic-Module-Name: com.example.wordcount
Built-By: zetcode
$ java -jar target/wordcount-1.0.0.jar a rose is a rose
Word count 1.0.0
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
at com.example.wordcount.SimpleWordCounter.count(SimpleWordCounter.java:16)
...
Class-Path and the lib directory
The JAR plugin can write the runtime dependencies into the
Class-Path attribute. The Dependency plugin copies them next
to the JAR.
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<archive>
<manifest>
<mainClass>com.example.wordcount.Main</mainClass>
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.11.0</version>
<executions>
<execution>
<id>copy-libs</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<includeScope>runtime</includeScope>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
With addClasspath, the manifest gets a
Class-Path attribute listing the file names of the compile
and runtime dependencies. The classpathPrefix is put in
front of each name.
<goal>copy-dependencies</goal>
The copy-dependencies goal of the
Dependency plugin copies the
runtime dependencies into target/lib in the same
package phase. Since it is declared after the JAR plugin, it
runs after it. The two lists match, because both plugins use the same
dependencies with the same file names.
The Class-Path entries are resolved relative to the
location of the JAR, not to the current directory. The application thus
runs from the project directory and from a dist directory
with the JAR and the lib directory, which can be zipped and
shipped. This is a thin JAR distribution: an update of the application
replaces a small JAR, and the libraries stay separate files.
$ mvn clean package --- jar:3.5.1:jar (default-jar) @ wordcount --- --- dependency:3.11.0:copy-dependencies (copy-libs) @ wordcount --- BUILD SUCCESS $ unzip -p target/wordcount-1.0.0.jar META-INF/MANIFEST.MF Manifest-Version: 1.0 Created-By: Maven JAR Plugin 3.5.1 Java-Version: 25 Build-Jdk-Spec: 25 Class-Path: lib/commons-lang3-3.21.0.jar Implementation-Title: Word count Implementation-Version: 1.0.0 Main-Class: com.example.wordcount.Main $ java -jar target/wordcount-1.0.0.jar a rose is a rose Word count 1.0.0 a 2 is 1 rose 2 $ mkdir dist && cp -r target/wordcount-1.0.0.jar target/lib dist $ find dist dist dist/wordcount-1.0.0.jar dist/lib dist/lib/commons-lang3-3.21.0.jar $ java -jar dist/wordcount-1.0.0.jar a rose Word count 1.0.0 a 1 rose 1
Additional JARs with classifiers
A project has one main artifact, but it can attach more files
distinguished by a classifier. We add a second execution of the
jar goal that packs only the API, and the
test-jar goal, which packs the test classes.
<executions>
<execution>
<id>api-jar</id>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<classifier>api</classifier>
<includes>
<include>com/example/wordcount/api/**</include>
</includes>
</configuration>
</execution>
<execution>
<id>test-jar</id>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
An execution without a phase element runs in the default
phase of its goal, which is package for both goals. The
classifier is added to the file name and to the coordinates
of the artifact; other projects refer to it with a
classifier element in the dependency. The
includes patterns limit the content to the API package.
<goal>test-jar</goal>
The test-jar goal packs target/test-classes
with the tests classifier. Other modules use it with
<type>test-jar</type> and the
test scope to share test utilities and fixtures.
All three JARs are built and later installed and deployed together. When
the tests are not compiled, as with -Dmaven.test.skip=true,
the test JAR is skipped. A module that depends on it then fails to
resolve it, so -DskipTests, which compiles the tests but does
not run them, is the safer option in such builds.
$ mvn clean package
--- jar:3.5.1:jar (default-jar) @ wordcount ---
Building jar: /home/user/project/wordcount/target/wordcount-1.0.0.jar
--- jar:3.5.1:jar (api-jar) @ wordcount ---
Building jar: /home/user/project/wordcount/target/wordcount-1.0.0-api.jar
--- jar:3.5.1:test-jar (test-jar) @ wordcount ---
Building jar: /home/user/project/wordcount/target/wordcount-1.0.0-tests.jar
BUILD SUCCESS
$ unzip -l target/wordcount-1.0.0-api.jar | grep class
280 1980-02-01 00:00 com/example/wordcount/api/WordCounter.class
$ unzip -l target/wordcount-1.0.0-tests.jar | grep class
1161 1980-02-01 00:00 com/example/wordcount/SimpleWordCounterTest.class
$ mvn clean package -Dmaven.test.skip=true
--- jar:3.5.1:test-jar (test-jar) @ wordcount ---
Skipping packaging of the test-jar
BUILD SUCCESS
$ ls target/*.jar
target/wordcount-1.0.0-api.jar
target/wordcount-1.0.0.jar
Entry timestamps
The timestamps of the JAR entries come from the
project.build.outputTimestamp property. Maven 3.10 sets it to
1980-02-01 when the project does not. We can set it to the release date
instead.
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.outputTimestamp>2026-11-13T10:00:00Z</project.build.outputTimestamp>
</properties>
The value is an ISO 8601 date and time. The Maven Release plugin updates it automatically during a release.
All entries have the given time, and two builds produce byte-identical JARs with the same SHA-256 checksum. Reproducible JARs let anyone verify that a published binary was built from the published sources.
$ unzip -l target/wordcount-1.0.0.jar | head -6
Archive: target/wordcount-1.0.0.jar
Length Date Time Name
--------- ---------- ----- ----
0 2026-11-13 10:00 META-INF/
99 2026-11-13 10:00 META-INF/MANIFEST.MF
0 2026-11-13 10:00 com/
$ sha256sum target/wordcount-1.0.0.jar
66a2f6bacc1fcee022fac76668d5ec7fa464e8547748bc55b3501071ae9eda04 target/wordcount-1.0.0.jar
$ mvn -q clean package && sha256sum target/wordcount-1.0.0.jar
66a2f6bacc1fcee022fac76668d5ec7fa464e8547748bc55b3501071ae9eda04 target/wordcount-1.0.0.jar
Useful options
| Parameter | Description |
|---|---|
archive | manifest and other archive settings |
includes, excludes | files to include or exclude |
classifier | classifier of an additional JAR |
classesDirectory | directory to pack, target/classes by default |
skipIfEmpty | do not create a JAR without content |
forceCreation | rebuild the JAR even if it is up to date |
outputTimestamp | timestamp of the entries |
Inside archive, the manifest element also knows
addDefaultSpecificationEntries,
addBuildEnvironmentEntries, and
classpathLayoutType; the manifestFile element
uses a prepared manifest file instead of generating one. The
addMavenDescriptor element set to false leaves
out the META-INF/maven directory.
Source
In this article we have worked with the Maven JAR Plugin. We have added
manifest entries, built a thin JAR with a lib directory,
created JARs with classifiers, and made the build reproducible.
Author
List all Java tutorials.