Maven 4: Lifecycle

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page goes deep on one part of Apache Maven 4: the lifecycle. Maven 4.0.0-rc-7 is a release candidate, not a final release, so everything below describes that candidate and can still change before 4.0.0. The overview of the whole release is on Maven 4: What’s New, the Maven 3 lifecycle (the three lifecycles, the phases, the bindings per packaging, default-* execution ids) is on Maven Build Lifecycle, and the move of a real project is on Migrating to Maven 4. Those pages are not repeated here. This one explains what they only mention: the tree, the hooks, the order of executions inside a phase and the concurrent builder.

All examples were run on 2026-10-16 with Maven 4.0.0-rc-7 on JDK 26 (the parallel experiment was repeated on JDK 17.0.14, the minimum Java for Maven 4) and, for the contrast, with Maven 3.10.0 (and 3.9.16 where stated). They use small scratch projects named after the tidepool project of this section and a scratch local repository, so nothing touched ~/.m2. Paths were shortened to /work/..., and the lines Loaded N auto-discovered prefixes ... that rc-7 prints on the first request to each repository were removed. Whenever output was filtered, the text says how.

How the lifecycle was observed

Phases are invisible unless something is bound to them, so every experiment uses the same probe: the Maven Antrun plugin (3.2.0) with one <echo> execution bound to a phase. A probe project binds three executions to each of the 19 phase names of the Maven 4 default lifecycle (before:X, X and after:X), 57 in all, and prints their execution ids and the goals of the real plugins in the order they ran. Two of the 57:

<execution>
  <id>b-compile</id>
  <phase>before:compile</phase>
  <goals><goal>run</goal></goals>
  <configuration><target><echo level="info" message="before:compile"/></target></configuration>
</execution>
<execution>
  <id>a-compile</id>
  <phase>after:compile</phase>
  <goals><goal>run</goal></goals>
  <configuration><target><echo level="info" message="after:compile"/></target></configuration>
</execution>

In the traces below an id starting with b- is the before: hook of the phase that follows, m- is an execution bound to the phase itself and a- is the after: hook; resources:resources, compiler:compile and the like are the default goals of the jar packaging. The traces keep only the --- plugin:goal (execution-id) @ project --- lines of the build log (reduced to the id) and are wrapped by hand. The probe project is this page’s own lab setup, not an example from the Maven documentation.

From a flat list to a tree

Maven 3 defines the default lifecycle as one ordered list of phases, from validate to deploy. The unknown-phase error of Maven 3.10.0 prints it:

$ mvn nonesuch
[ERROR] Unknown lifecycle phase "nonesuch". Available lifecycle phases are: pre-clean, clean, post-clean, validate, initialize, generate-sources, process-sources, generate-resources, process-resources, compile, process-classes, generate-test-sources, process-test-sources, generate-test-resources, process-test-resources, test-compile, process-test-classes, test, prepare-package, package, pre-integration-test, integration-test, post-integration-test, verify, install, deploy, pre-site, site, post-site, site-deploy. -> [Help 1]

(A sentence of the message was left out.) Maven 4.0.0-rc-7 keeps a list for the command line, but a different one, and the lifecycle behind it is a tree. The same error, provoked with the phase name each:

$ mvn each
[ERROR] Unknown lifecycle phase "each". Available lifecycle phases are: before:clean, clean, after:clean, before:all, before:initialize, before:validate, validate, after:validate, initialize, after:initialize, before:build, before:sources, sources, after:sources, before:resources, resources, after:resources, before:compile, compile, after:compile, before:ready, ready, after:ready, before:test-sources, test-sources, after:test-sources, before:test-resources, test-resources, after:test-resources, before:test-compile, test-compile, after:test-compile, before:test, test, after:test, before:unit-test, unit-test, after:unit-test, before:package, package, after:package, build, after:build, before:verify, before:integration-test, integration-test, after:integration-test, verify, after:verify, before:install, install, after:install, before:deploy, deploy, after:deploy, all, after:all, before:site, site, after:site, before:site-deploy, site-deploy, after:site-deploy. -> [Help 1]

Three things stand out in that list. Every phase has a before: and an after: form, the old generate-sources, process-resources, prepare-package and friends are gone from it (they still work, as the section on aliases shows) and each is not a phase you can type in rc-7, although all is. The Lifecycles Reference of rc-7 prints the tree itself, from which the table below is taken (the goals are those of the jar packaging, run in the first section).

Phase Child of Runs after Default jar goal

all

(root)

its each child, and the all phases of subprojects

none

each

all

(wraps the phases below)

none

validate

each

none

none; initialize is its child

build

each

validate

none

sources, resources

build

none

resources:resources on resources

compile

build

sources; the ready phase of its compile-scope dependencies

compiler:compile

ready

build

compile and resources

none

package

build

ready; the package phase of its runtime-scope dependencies

jar:jar

verify

each

validate (not package)

none

unit-test

verify

none

none

test-sources, test-resources

unit-test

none

resources:testResources on test-resources

test-compile

unit-test

test-sources and ready; the ready phase of its test-only dependencies

compiler:testCompile

test

unit-test

test-compile and test-resources; the ready phase of its test-scope dependencies

surefire:test

integration-test

verify

none

none

install, deploy

each

package

install:install, deploy:deploy

The Maven 4 default lifecycle as nested boxes: all contains each, which contains validate with initialize, build with sources, resources, compile, ready and package, verify with unit-test and integration-test, install and deploy, with arrows for the runs-after relations and the default jar goals

Two details of the tree deserve emphasis. A ready phase exists because most of what a dependent project needs from a dependency is its compiled classes and resources, not its finished jar, so a dependent can start at ready. And verify runs after validate only: the tree does not say that verification must wait for packaging, which is the difference the concurrent builder makes visible later on this page. The maven-core reference page lists the same tree, including the clean and site lifecycles (site-deploy runs after site).

Before and after hooks

Every phase now has a before: and an after: phase to bind to. The probe project (57 executions) was run with mvn verify. Maven 3.10.0, given a POM that is the same except for model version 4.0.0, ran 8 of the 57 and said nothing about the other 49:

$ mvn verify
m-validate m-initialize resources:resources compiler:compile m-compile resources:testResources
compiler:testCompile m-test-compile surefire:test m-test jar:jar m-package m-integration-test
m-verify

Only the executions bound to phases that already exist in Maven 3 ran: validate, initialize, compile, test-compile, test, package, integration-test and verify. An execution bound to before:compile or sources is never bound and the build still ends with BUILD SUCCESS, with no warning. Maven 3.9.16 ran the same 8. Maven 4.0.0-rc-7 ran 47 of the 57 (the 10 missing are the each hooks, all, and the install and deploy ones, which verify does not reach):

$ mvn verify
b-all b-initialize b-validate m-validate a-validate m-initialize a-initialize b-build b-sources
m-sources a-sources b-resources m-resources resources:resources a-resources b-compile
compiler:compile m-compile a-compile b-ready m-ready a-ready b-test-sources m-test-sources
a-test-sources b-test-resources m-test-resources resources:testResources a-test-resources
b-test-compile compiler:testCompile m-test-compile a-test-compile b-test surefire:test m-test
a-test b-unit-test m-unit-test a-unit-test b-package jar:jar m-package a-package m-build a-build
b-verify b-integration-test m-integration-test a-integration-test m-verify a-verify a-all

What this trace shows:

  • The Maven 3 order is kept. Resources, compile, test resources, test compile, test, jar: verify still builds the jar before verify ends, so the default builder behaves like Maven 3, as the release notes promise.

  • Each phase is wrapped. b-compile, then compiler:compile, then m-compile, then a-compile: an execution bound to the bare phase name ran after the default goals of that phase, exactly as in Maven 3, and the after: hook came last.

  • Parents wrap children. validate has the child initialize, and the output order is m-validate a-validate m-initialize a-initialize. In this build the hook b-initialize printed before b-validate. The phases of the tree are walked in the order the list above gives, and the list puts before:initialize first. Do not depend on the relative order of a before: hook of a child and of its parent.

  • after: hooks of the ancestors run. a-build ran after m-build and the last line, a-all, closed the whole build. The execution bound to the bare phase all (m-all) never ran, and neither did anything bound to each.

  • The hooks of integration-test run although the jar has no goal there. An empty phase still fires its hooks.

Running a hook as the goal of the command line behaves like running the phase itself. mvn before:compile, mvn after:compile and mvn compile produced the same trace in this project, all the way to a-build and a-all, including the compiler goal. The same holds for mvn prepare-package (an alias, see below) and mvn before:package: both run the jar goal. Maven 3.10.0 stops prepare-package before jar:jar; in Maven 4 the default builder does not, because the hook always comes with its phase.

This is the behaviour change that the overview page calls "phase execution scope": the after: hook of a phase you reach always runs, so a plugin bound to post-clean runs on mvn clean.

Ordering executions inside a phase

Several executions can be bound to the same phase, and in Maven 3 they run in the order the plugins and executions are declared in the POM. Maven 4 lets you add an integer in square brackets to the phase name, a priority. The experiment binds ten executions of the Antrun plugin to compile and its hooks, in a scrambled order in the POM (the execution ids e1 to e10 are their declaration order):

<execution><id>e1</id><phase>compile</phase>...</execution>
<execution><id>e2</id><phase>compile[200]</phase>...</execution>
<execution><id>e3</id><phase>compile[50]</phase>...</execution>
<execution><id>e4</id><phase>before:compile</phase>...</execution>
<execution><id>e5</id><phase>before:compile[10]</phase>...</execution>
<execution><id>e6</id><phase>after:compile[5]</phase>...</execution>
<execution><id>e7</id><phase>after:compile</phase>...</execution>
<execution><id>e8</id><phase>after:compile[-5]</phase>...</execution>
<execution><id>e9</id><phase>after:compile[100]</phase>...</execution>
<execution><id>e10</id><phase>after:compile[1]</phase>...</execution>

Each execution echoes its id and phase. mvn compile on rc-7 printed (the (compiler:compile) line stands for the log line of the compiler goal):

e4 before:compile
e5 before:compile[10]
(compiler:compile)
e1 compile
e3 compile[50]
e2 compile[200]
e8 after:compile[-5]
e7 after:compile
e10 after:compile[1]
e6 after:compile[5]
e9 after:compile[100]

The rules the output demonstrates:

  • Within one phase, executions run in ascending order of the number, and negative numbers are allowed.

  • An execution without a number counts as 0: e7 (no number) ran between -5 and 1, and e4 ran before e5, which has 10.

  • Among the executions with the same number, the declaration order decides, as it did in Maven 3. The default goal of the packaging (compiler:compile) ran before e1 in the phase compile, because the lifecycle bindings come first.

  • The numbers only order executions of the same phase. They never move an execution to another phase.

The Maven 4 plugin descriptor META-INF/maven/lifecycle.xml (used by plugins that define their own lifecycle) has the same concept under another name: a <phase> element takes an executionPoint attribute (such as before or after, which decorates the phase named by <id>) and a priority, an integer whose default is 0. That default matches the unnumbered executions above.

Maven 3.10.0 and 3.9.16 do not know the syntax: running the same project on both printed only e1 compile after the compiler goal, so the other nine executions, the numbered ones and the hooks, were never bound and the build still succeeded. The overview page measured the same for validate[200], so do not use a bracketed phase in a POM that must still build with Maven 3. Section The concurrent builder adds that the concurrent builder of rc-7 also ignores the numbered executions.

The old pre-* and post-* names are aliases

Maven 3 had a few phases named pre-* and post-* and many with the generate-*, process-* and prepare-package names. In Maven 4 they survive as aliases of tree phases, and the release notes warn against binding to them. To see which phase each alias maps to, a probe project bound one execution to each old name and one to its target, and ran mvn verify. Each pair fired back to back, which gave this table (for the clean and site lifecycles the pairs are pre-clean with before:clean, post-clean with after:clean, pre-site with before:site and post-site with after:site):

Maven 3 name Maven 4 phase it runs in Observed position in mvn verify

generate-sources

sources

before resources, nothing bound by default

process-sources

after:sources

right after sources

generate-resources

resources

before resources:resources

process-resources

after:resources

right after resources:resources

process-classes

after:compile

right after compiler:compile

generate-test-sources

test-sources

after the compile goal, before resources:testResources

process-test-sources

after:test-sources

right after test-sources

generate-test-resources

test-resources

before resources:testResources

process-test-resources

after:test-resources

right after resources:testResources

process-test-classes

after:test-compile

right after compiler:testCompile

prepare-package

before:package

right before jar:jar

pre-integration-test

before:integration-test

after package, before integration-test

post-integration-test

after:integration-test

right after integration-test

The experiment’s trace, reduced to the old and new names and the real goals (each pair appeared in this order):

old generate-sources, new sources, old process-sources, new after:sources
old generate-resources, new resources, resources:resources, old process-resources, new after:resources
compiler:compile, old process-classes, new after:compile
old generate-test-sources, new test-sources, old process-test-sources, new after:test-sources
old generate-test-resources, new test-resources, old process-test-resources, new after:test-resources
old process-test-classes, new after:test-compile, old prepare-package, new before:package
jar:jar, old pre-integration-test, new before:integration-test, old post-integration-test, new after:integration-test

The command line accepts the same aliases: mvn process-classes ran resources:resources and compiler:compile, like mvn after:compile. The warning of the official article applies to the post-* names above all: a plugin bound to process-resources, because there is no pre-compile, is really bound to after:resources, which may not be what its author meant. For the migration step that replaces the names, with mvnup, see Migrating to Maven 4; the migration guide lists the replacement of pre-integration-test and post-integration-test with before:integration-test and after:integration-test as a required change of step 2.

The concurrent builder

The tree only changes how a build runs when you ask for it. Maven picks a builder, the strategy that walks the reactor and the phases, and rc-7 has three. -b or --builder selects one by id (the option text is the same in Maven 3.10.0 and rc-7: "The id of the build strategy to use"):

Id Maven 3.10.0 Maven 4.0.0-rc-7

singlethreaded

used without -T; accepted with -b

used without -T; accepted with -b

multithreaded

selected by -T (log line Using the MultiThreadedBuilder implementation ...); accepted with -b

selected by -T; its log line is Using the MultiThreadedBuilder implementation ...

concurrent

unknown

opt-in; its log line is Using the BuildPlanExecutor implementation ...

An unknown id fails at once. Maven 3.10.0 for -b concurrent and rc-7 for -b nonesuch:

[ERROR] The builder requested using id = concurrent cannot be found -> [Help 1]
[ERROR] The builder requested using id = nonesuch cannot be found -> [Help 1]

The concurrent builder is the only one that plans the build from the tree. It computes, for the phase you requested, the phases that it depends on, in all the projects, and runs whatever is ready. Everything that follows on this page about "concurrent" is that builder; the flag does nothing else.

What a requested phase includes

With the default builder, mvn package runs everything from validate to package. With the concurrent builder it runs the phase and the phases it depends on in the tree. The real goals of the jar packaging that each command ran in the single-project probe, with both builders (mvn <phase> and mvn -b concurrent <phase>):

Command Default builder -b concurrent

compile

resources:resources compiler:compile

compiler:compile

test-compile

resources:resources compiler:compile resources:testResources compiler:testCompile

resources:resources compiler:compile compiler:testCompile

test

resources:resources compiler:compile resources:testResources compiler:testCompile surefire:test

resources:resources resources:testResources compiler:compile compiler:testCompile surefire:test

package

resources:resources compiler:compile resources:testResources compiler:testCompile surefire:test jar:jar

resources:resources compiler:compile jar:jar

verify

resources:resources compiler:compile resources:testResources compiler:testCompile surefire:test jar:jar

resources:resources resources:testResources compiler:compile compiler:testCompile surefire:test

install

all of the above, then install:install

resources:resources compiler:compile jar:jar install:install

deploy

all of the above, then install:install and deploy:deploy

resources:resources compiler:compile jar:jar deploy:deploy

The right column follows from the tree and is not what a Maven 3 user expects:

  • compile runs no resources:resources, because compile runs after sources, not after resources; ready is the phase that waits for both.

  • package and install run no tests, because package runs after ready and the tests are under verify.

  • verify runs the tests but builds no jar, and deploy deploys without installing first.

  • integration-test alone ran no goal at all in this project.

The default builder keeps the Maven 3 behaviour, including mvn install after verify and mvn deploy running install first. A trace of the probe project, default builder, mvn install ended with b-install install:install m-install a-install a-all. The concurrent trace of the same command (the order can differ between runs when more than one thread works, but this was the trace of a run):

$ mvn -b concurrent install
b-all b-each b-validate b-initialize m-validate m-initialize a-initialize a-validate b-build
b-resources b-sources m-resources m-sources resources:resources a-resources a-sources b-compile
compiler:compile m-compile a-compile b-ready m-ready a-ready b-package jar:jar m-package a-package
b-install a-build install:install m-install a-install a-each a-all

Here b-each and a-each run, and so do the parent hooks m-validate m-initialize a-initialize a-validate, properly nested: the concurrent builder follows the tree exactly where the default builder follows the flattened list.

Where a dependent project can start

The tree lets a project start once the phase it needs from a dependency is done. The experiment: tidepool-engine depends on tidepool-model, and tidepool-model sleeps 3 s in before:unit-test, with echo hooks that print the time. The default multithreaded builder and the concurrent builder were run with two threads. The echo lines are all the output of the experiment that was kept:

$ mvn -T 2 verify
[INFO] Using the MultiThreadedBuilder implementation with a thread count of 2
  40.093 model: after:ready
  40.162 model: before:unit-test (sleeping 3 s)
  43.208 model: after:unit-test
  43.420 engine: before:compile
[INFO] BUILD SUCCESS
[INFO] Total time:  4.021 s (Wall Clock)

$ mvn -b concurrent -T 2 verify
[INFO] Using the BuildPlanExecutor implementation with a thread count of 2
  44.393 model: before:unit-test (sleeping 3 s)
  44.703 model: after:ready
  44.892 engine: before:compile
  47.425 model: after:unit-test
[INFO] BUILD SUCCESS
[INFO] Total time:  3.381 s (Wall Clock)

The default builder starts the engine 3.33 s after the model reached ready, because it waits for the whole verify of the model, the 3 s sleep included. The concurrent builder starts it 0.2 s after ready and the compile of the engine overlaps the model’s tests. A project with long tests and many dependents gains most. Repeating the concurrent run on JDK 17.0.14 gave the same shape.

Two timelines of the same build: with the default builder tidepool-engine starts at 3.33 seconds after tidepool-model finished its 3 second sleep; with the concurrent builder it starts at 0.50 seconds, while the sleep is still running

The thread count matters. Without -T, -b concurrent ran the same project with one thread: after:ready printed only after the sleep, and the engine started as soon as it ended (3.9 s in all). So pass -T (a count such as -T 4 or -T 1C) when you choose the concurrent builder, as with the default one; see Build Performance: Cache, Parallel Builds & mvnd.

The all and each phases across subprojects

all and each exist for builds with subprojects (see Multi-Module Builds & the Reactor): the parent tidepool-parent has the subprojects tidepool-model and tidepool-engine, and each of the three has probes on all, each, compile and package. The lines below keep only the all and each hooks of mvn compile (the project name is in the echo message):

$ mvn compile
parent before:all;parent after:all;model before:all;model after:all;engine before:all;engine after:all;

$ mvn -b concurrent -T 1 compile
parent before:all;parent before:each;model before:all;model before:each;parent after:each;engine before:all;
engine before:each;model after:each;model after:all;engine after:each;engine after:all;parent after:all;

With the default builder every project runs its own all hooks, one project after the other, and each is not run at all. With the concurrent builder, the documented model shows up: the before:all of the parent comes first, the after:all of the parent comes last, after the all hooks of the subprojects, and the each hooks wrap the phases of one project. So "run this once before everything" and "run this once after everything" are before:all and after:all of the root project, but only with the concurrent builder. With the default builder, use a goal of the root project that you invoke yourself, or the hooks of the root as a per-project step.

The same command gave the same order on two runs with -T 1; with more threads the lines of different projects interleave.

Other behaviours of the concurrent builder in rc-7

These were each observed on rc-7, and some look like gaps of the release candidate rather than design; they are listed so that a trial does not surprise you.

  • Only the first phase of a lifecycle is honoured. mvn -b concurrent verify install ran what verify runs; mvn -b concurrent install verify ran what install runs; package compile ran package. A clean before it is a separate lifecycle and is honoured: mvn -b concurrent clean package install cleaned and packaged but did not install. Run one build per phase.

  • Goals given on the command line are not ordered after the phases. mvn -b concurrent compile jar:jar ran jar:jar before compiler:compile.

  • Numbered executions are skipped. In the ordering experiment, mvn -b concurrent compile printed e4 before:compile, the compiler, e1 compile and e7 after:compile, and none of the seven executions with a number in brackets.

  • after: hooks run after a failure. With a source file that does not compile, the default builder stopped at compiler:compile and printed no hook after it; the concurrent builder ended with a-compile a-build a-each a-all:

    $ mvn compile
    b-all b-initialize b-validate m-validate a-validate m-initialize a-initialize b-build b-sources
    m-sources a-sources b-resources m-resources resources:resources a-resources b-compile
    compiler:compile [ERROR] BUILD FAILURE
    
    $ mvn -b concurrent compile
    b-all b-each b-validate b-initialize m-validate m-initialize a-initialize a-validate b-build
    b-sources m-sources a-sources b-compile compiler:compile a-compile a-build a-each a-all [ERROR]
    BUILD FAILURE

    That is useful for cleanup hooks (an after:all that stops a test server), and a reason to write them so that they cope with a build that stopped halfway. The execution bound to compile itself did not run after the failed compiler goal; only the after: hooks did.

  • Everything else about the experiment stayed correct. The build result, the reactor summary and the inter-project classpath were right: in the two-subproject build mvn -b concurrent verify compiled tidepool-engine against the classes of tidepool-model although neither jar was built, because verify does not run package.

The section Practical advice below turns this into a policy. The release notes and the overview page say the same in a sentence: treat -b concurrent as an experiment in the release candidate, compare the goals it ran with the default builder’s before relying on it, and keep it out of your release job until Maven 4.0.0 is final.

Practical advice

  • Use before: and after: instead of the old names for every new binding, and convert existing ones with the Maven upgrade tool (mvnup) once the build works on Maven 4; the overview page shows it rewriting post-clean to after:clean.

  • A POM that must still build with Maven 3 should not use the new phase names at all. Maven 3.10.0 and 3.9.16 drop an execution bound to before:compile, after:compile[200], sources, ready or all silently, with BUILD SUCCESS; so a quality gate bound there would just disappear. The only safe bindings for both are the Maven 3 names that also exist in the tree: validate, initialize, compile, test-compile, test, package, integration-test, verify, install and deploy.

  • Check your post-* bindings. On Maven 4 they run when you build up to the main phase; see the behaviour change above.

  • Prefer the default builder for a release job. It keeps the Maven 3 behaviour, and the three surprises of the concurrent builder (no tests with package and install, no jar with verify, one phase per lifecycle) are the kind of thing a release pipeline cannot absorb.

  • Try the concurrent builder in a CI job that runs -T 1C verify, compare its goal list with the default builder’s (the help:describe goal of rc-7 is not tree-aware, as the Maven Build Lifecycle page shows, so read the build log) and watch the time of projects whose dependents wait on long tests.

  • Use the each and all hooks for tasks tied to a project or to the whole build (starting a test container before everything, stopping it after everything), knowing that the once-per-build meaning needs the concurrent builder in rc-7.

Equivalents in Ant and Gradle

Maven 4 Ant Gradle

before:compile, after:compile hooks

Nothing built in; put the step first or last in the target, or add a target that the original depends on.

dependsOn for a step before, finalizedBy for one after (it runs even if the task fails, like an after: hook of the concurrent builder).

compile[200] priorities

The order of the tasks inside a target.

mustRunAfter and shouldRunAfter between tasks.

all and each

No notion; a subant over several build files is the closest thing.

Lifecycle tasks of the root project, allprojects and subprojects.

Concurrent builder and ready

Not available; targets run one after the other in one thread.

A task graph executed in parallel with --parallel, where a project depends on the tasks (not the whole build) of another.

The Ant model is on Ant Targets & Extension Points and the Gradle one on Gradle Build Lifecycle.