The build was green at 14:02. The staging deploy finished at 14:06. The first request hit it at 14:07 and came back a 500, and the log said java.lang.NoClassDefFoundError: com/acme/pricing/TaxRuleSet. I spent the next hour convinced that the jar was missing a class, because that is what the message says, and the message is a lie about half the time.
It was not missing. jar tf found com/acme/pricing/TaxRuleSet.class sitting exactly where it should be, 4.1 kB, same timestamp as everything else in the artifact. The class was there. It just could not be used, and the reason had been printed forty seconds earlier in a stack trace I had scrolled straight past because it named a different class entirely.
That hour is why this post exists. The two failures people conflate — ClassNotFoundException and NoClassDefFoundError — look like synonyms and are not related in any useful way. They live on opposite sides of the Throwable hierarchy, they are thrown by different machinery, and they are produced by different mistakes.
One of them is a checked exception you asked for. The other is an Error the JVM hands you. Confusing them sends you looking in the wrong place, and in my case it cost an hour of staring at a classpath that was completely fine.
Table of Contents
- What the Two Actually Mean
- Why It Happens: Loading, Linking, Initialising
- Cause 1: A Reflectively Loaded Class That Isn't on the Classpath
- Cause 2: Compiled Against It, Shipped Without It
- Cause 3: The Static Initialiser That Threw an Hour Ago
- How to Fix Each One
- How to Prevent It
- Frequently Asked Questions
What the Two Actually Mean
Start with the hierarchies, because everything else follows from them.
java.lang.Throwable
├── java.lang.Exception
│ └── java.lang.ReflectiveOperationException
│ └── java.lang.ClassNotFoundException // CHECKED
└── java.lang.Error
└── java.lang.LinkageError
├── java.lang.NoClassDefFoundError // unchecked
├── java.lang.ExceptionInInitializerError
├── java.lang.NoSuchMethodError
├── java.lang.NoSuchFieldError
└── java.lang.IncompatibleClassChangeError
ClassNotFoundException is checked, and it is checked for a good reason: it is thrown only by methods that take a class name as a string — Class.forName(String), ClassLoader.loadClass(String), ClassLoader.findSystemClass(String). A string is runtime input. The compiler cannot verify it, so the language makes you say what happens when it is wrong.
NoClassDefFoundError sits under LinkageError alongside NoSuchMethodError and IncompatibleClassChangeError — the family that means the pieces you compiled against and the pieces you are running against do not agree. The class was present when this code was compiled. javac resolved the reference, wrote a symbolic one into the constant pool, and moved on. At runtime the JVM went to link it and could not.
Which gives you the one distinction that actually works in front of a log file. A ClassNotFoundException means somebody asked by name — your code, a framework, a driver registry — so go find the string. A NoClassDefFoundError means the JVM tried to link a reference javac had already resolved, so go find the difference between the build classpath and the runtime classpath. Different question, different file, different afternoon.
They can also stack. A framework calls Class.forName, catches the resulting ClassNotFoundException, and rethrows it wrapped in a NoClassDefFoundError because it has no checked exception to declare. That is where the "I'm seeing both" confusion usually comes from, and a Caused by: line will show it.
Why It Happens: Loading, Linking, Initialising
The JLS splits bringing a class into existence into three phases, and each one throws differently. Knowing which phase you are in narrows the search enormously.
Loading finds the bytes. A class loader is asked for a binary name, it locates the .class data from wherever it draws from — a directory, a jar, a network stream, a byte array it generated itself — and defines a Class object from it. Failure here means the bytes were not found. If the caller asked by name, they get ClassNotFoundException. If the JVM was resolving a reference on its own behalf, it gets NoClassDefFoundError.
Linking is verification, preparation and resolution. Verification checks the bytecode is well formed; preparation allocates static fields at their default values and runs no user code at all. Resolution is the interesting one: it turns the symbolic references in the constant pool into direct ones, recursively demanding the classes your class mentions. A class that vanished between builds becomes a NoClassDefFoundError here, and a member that vanished becomes a NoSuchMethodError.
Initialising runs <clinit>: static field assignments and static blocks, in source order, exactly once, guarded by a per-class lock. This is the only phase that executes code you wrote, and therefore the only phase that can fail for a reason that has nothing to do with the classpath.
Loaders delegate parent-first by default: the application loader hands a request up to the platform loader, which hands it to the bootstrap loader, and only when the parent chain gives up does the child search its own classpath. That is what stops your com.acme.String from shadowing java.lang.String. It is also why "the jar is right there and it still can't find it" is a real situation — being on a classpath is not being visible to this loader.
Now the single fact that shortens most of these debugging sessions. NoClassDefFoundError has two distinct causes and they produce two different messages:
java.lang.NoClassDefFoundError: com/acme/pricing/TaxRuleSet
→ the class really is absent from every loader that was consulted
java.lang.NoClassDefFoundError: Could not initialize class com.acme.pricing.TaxRuleSet
→ the class was FOUND. Its static initialiser threw. The JVM has marked it
erroneous, permanently, for that loader.
Note the slash-versus-dot difference in the two names as well. The first is an internal binary name from the linking machinery; the second is a normal source-form name written into a message string. It is a small tell, and it is a real one.
If you only take one thing from this post: "Could not initialize class" does not mean the class is missing. It means the opposite. That was my lost hour, in one sentence.
Cause 1: A Reflectively Loaded Class That Isn't on the Classpath
The plainest case, and the only one that gives you a genuine ClassNotFoundException. Some name arrives from configuration and gets handed to Class.forName. A JDBC driver, a plugin, a strategy class chosen per tenant:
package com.acme.pricing;
import java.util.Map;
public final class RuleEngineFactory {
/** Instantiates the rule engine named in tenant config, e.g. "com.acme.pricing.gst.GstEngine". */
static RuleEngine create(Map<String, String> config) throws ReflectiveOperationException {
String className = config.get("pricing.engine.class");
Class<?> type = Class.forName(className); // name is runtime input
return (RuleEngine) type.getDeclaredConstructor().newInstance();
}
}
Exception in thread "main" java.lang.ClassNotFoundException: com.acme.pricing.gst.GstEngine
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
at java.base/java.lang.Class.forName0(Native Method)
at java.base/java.lang.Class.forName(Class.java:375)
at com.acme.pricing.RuleEngineFactory.create(RuleEngineFactory.java:11)
Read the trace from the bottom: forName, the app loader, the builtin loader failing to find the resource. No user frame sits between create and the JDK, because the whole operation is a lookup.
The compiler could not have caught this. It never saw com.acme.pricing.gst.GstEngine as a type; it saw a String and a method returning Class<?>. Change one character in a properties file and this compiles identically and fails identically. That is precisely why ClassNotFoundException is checked: a name you did not write down at compile time may not exist at run time, and you have to decide what that means.
Two things make this fail in practice more often than a typo. The class is in an optional module that was not packaged for this deployment — the GST engine ships only to the India build. Or the class exists but is not visible to the loader that Class.forName(String) used, which is the caller's own defining loader and not necessarily the one you expected. More on that in fix 4.
One free win here: the old Class.forName("com.mysql.cj.jdbc.Driver") ritual has been unnecessary since JDBC 4.0, because DriverManager discovers drivers through ServiceLoader. Deleting the line removes a whole category of this failure at no cost.
Cause 2: Compiled Against It, Shipped Without It
Classpath drift. The code compiles because the dependency is on the compile classpath, and it fails at runtime because the artifact you deployed does not contain it. No reflection anywhere — this is an ordinary new or a static call:
package com.acme.pricing;
import com.acme.tax.TaxRuleSet; // resolved at compile time, absent at runtime
public final class InvoicePricer {
private final TaxRuleSet rules = TaxRuleSet.defaults();
public long priceMinor(long netMinor) {
return netMinor + rules.taxOn(netMinor);
}
}
java.lang.NoClassDefFoundError: com/acme/tax/TaxRuleSet
at com.acme.pricing.InvoicePricer.<init>(InvoicePricer.java:7)
at com.acme.pricing.PricingModule.pricer(PricingModule.java:24)
...
Caused by: java.lang.ClassNotFoundException: com.acme.tax.TaxRuleSet
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
... 2 more
That Caused by is the one to internalise. The JVM asked a loader for the class, the loader threw ClassNotFoundException because that is what loaders throw, and the linking machinery wrapped it in the Error the specification requires. Same event, two throwables, and the outer one is what people quote in bug reports.
Three ways I have actually caused this. A wrong Maven scope. <scope>provided</scope> means "the runtime environment supplies this" — correct for a servlet API in a WAR, wrong the moment you run the same code standalone. <scope>test</scope> is worse, because a class that drifted from test-only helper to production dependency compiles fine in the test tree and disappears from the shipped jar.
A shaded jar with an incomplete relocation. If the shade plugin relocates com.acme.tax to com.acme.shaded.tax but a filter excluded part of that package, the rewritten bytecode points at a name that nothing defines. The jar is bigger than ever and the class is gone.
A version bump that removed a class. Semantic versioning is a promise, not a mechanism. A minor bump that deleted a deprecated type gives you exactly this, and its siblings NoSuchMethodError and NoSuchFieldError when it was only a member that went.
Two commands settle which it is:
$ jar tf target/pricing-service-2.14.0.jar | grep -i taxruleset
(no output — it is genuinely not in the artifact)
$ mvn -q dependency:tree -Dincludes=com.acme:tax
[INFO] com.acme:pricing-service:jar:2.14.0
[INFO] \- com.acme:tax:jar:1.9.2:provided ← there it is
Empty jar tf plus a provided in the tree is a closed case in under a minute. The class was on the compile classpath by way of a scope that promised something else would supply it at runtime, and nothing did.
Cause 3: The Static Initialiser That Threw an Hour Ago
This is the one that cost me the afternoon, and the reason I wanted to write this post rather than just remember it.
package com.acme.pricing;
import java.util.Map;
public final class TaxRuleSet {
private static final Map<String, Integer> RATES_BP = loadRates();
private static Map<String, Integer> loadRates() {
String path = System.getenv("TAX_RATES_PATH"); // unset in the new environment
return RateFileParser.parse(path); // NPEs on a null path
}
static int rateBasisPoints(String hsn) {
return RATES_BP.getOrDefault(hsn, 1800);
}
}
The very first thread to touch TaxRuleSet gets the honest failure, and it is a good one:
java.lang.ExceptionInInitializerError
at com.acme.pricing.InvoicePricer.priceMinor(InvoicePricer.java:14)
at com.acme.http.PricingHandler.handle(PricingHandler.java:63)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.lang.NullPointerException: Cannot invoke "String.length()" because "path" is null
at com.acme.pricing.RateFileParser.parse(RateFileParser.java:28)
at com.acme.pricing.TaxRuleSet.loadRates(TaxRuleSet.java:11)
at com.acme.pricing.TaxRuleSet.<clinit>(TaxRuleSet.java:7)
... 3 more
Everything you need is in there. The <clinit> frame names the phase, and the cause is a helpful NullPointerException message that names the null variable outright. Thirty seconds of work, if you see it.
Now the second request arrives:
java.lang.NoClassDefFoundError: Could not initialize class com.acme.pricing.TaxRuleSet
at com.acme.pricing.InvoicePricer.priceMinor(InvoicePricer.java:14)
at com.acme.http.PricingHandler.handle(PricingHandler.java:63)
at java.base/java.lang.Thread.run(Thread.java:1583)
No cause. No <clinit> frame. No mention of an environment variable, a file, or a parser. Three frames, and the top one says a class could not be found — a class that is sitting in the jar.
The mechanism is in the JLS and it is deliberate. Initialisation happens once. If <clinit> completes abruptly, the JVM marks that class erroneous for that defining loader and every later attempt to use it fails immediately. It does not retry, and it cannot — static finals may be half-assigned, and re-running an initialiser that already had side effects would be worse than refusing. The marking is permanent for the life of that loader, which for an application class usually means the life of the process.
So the first failure carries the diagnosis and every failure after it carries nothing. In a service under load the first one lands during warmup, on a background thread, buried among a hundred other lines, and the ten thousand you actually look at are all the useless variant.
Say it plainly: if you are staring at "Could not initialize class", the useful log line is further UP the file, not in front of you. Search backwards for ExceptionInInitializerError and the name of that class. Do not filter by thread, do not filter by request id, and widen the time window well past where you think the incident started. Mine was forty seconds and one thread name away, and I read straight past it because it named RateFileParser rather than TaxRuleSet.
One more sharp edge. An initialiser that throws an Error rather than an Exception is not wrapped — ExceptionInInitializerError only wraps things that are not already Errors. An OutOfMemoryError inside a static block propagates as itself, and the erroneous marking still happens. You get the "could not initialize" messages afterwards with no ExceptionInInitializerError anywhere to search for.
How to Fix Each One
1. Confirm what is actually on the classpath
Before theorising, look. These are in rough order of how quickly they pay off:
# is the class in the artifact at all?
$ jar tf build/libs/pricing-service.jar | grep TaxRuleSet
com/acme/pricing/TaxRuleSet.class
# same question for a nested or third-party jar
$ unzip -l lib/tax-1.9.2.jar | grep -c '\.class$'
# which loader actually loaded it, and from where?
$ java -verbose:class -jar pricing-service.jar 2>&1 | grep TaxRuleSet
[0.412s][info][class,load] com.acme.pricing.TaxRuleSet source: file:/opt/app/pricing-service.jar
# loaders in a running process, no restart required
$ jcmd 48812 VM.classloader_stats
-verbose:class is the one that ends arguments, because it prints the source of every class the JVM loaded — two jars on the classpath containing the same package, and this tells you which one won. The cost is real: tens of thousands of lines on a large application, so pipe it and grep rather than reading it. On a live process jcmd <pid> VM.classloader_stats gives you the loader tree without a restart, which matters when restarting is what makes the symptom disappear.
2. Fix the dependency, not the symptom
$ mvn dependency:tree -Dverbose -Dincludes=com.acme:tax
$ jdeps --print-module-deps --ignore-missing-deps target/pricing-service.jar
If the scope is wrong, change the scope. If a shade relocation dropped a package, fix the <relocation> or <filter> rather than adding the jar back alongside the fat one — two copies of one package on a classpath is how you turn a clean NoClassDefFoundError into an intermittent NoSuchMethodError, which is a considerably worse day. jdeps lists what an artifact actually references, and --ignore-missing-deps lets it complete on the broken artifact instead of refusing.
When it is the wrong move: bumping a version to make the class reappear without checking why it went. If a class was deleted in a minor release, something replaced it, and pinning the old version buys time rather than fixing anything.
3. Recover the first exception for the initialiser case
No command for this one. It is a search: grep the log backwards for ExceptionInInitializerError, for <clinit>, or for the class name in the "could not initialize" message. Widen the window generously — on a container that starts eagerly, the first touch can be minutes before the request that reported it.
If the log is gone, force it to happen again in isolation:
// com/acme/diag/TouchClass.java — run against the same jar, same environment
package com.acme.diag;
public final class TouchClass {
public static void main(String[] args) throws Exception {
Class.forName(args[0]); // forces load, link and <clinit>
System.out.println("initialised: " + args[0]);
}
}
$ java -cp pricing-service.jar com.acme.diag.TouchClass com.acme.pricing.TaxRuleSet
Exception in thread "main" java.lang.ExceptionInInitializerError
...
Caused by: java.lang.NullPointerException: Cannot invoke "String.length()" because "path" is null
A fresh JVM has no erroneous marking yet, so the first touch gives you the real cause with its stack. That is also why "restarting fixes it" is so misleading here: the restart clears the erroneous state, the class is re-initialised, and it either succeeds because the transient condition passed or fails again with the good message that nobody was watching for. Restarting does not fix a static initialiser; it re-rolls the dice and resets the evidence.
The durable fix is to stop the initialiser from being able to throw — move the work into a method called at a point where you can handle failure. That has a cost: you lose the once-only guarantee and the free thread safety of <clinit>, and you have to think about who calls it and when. Worth it for anything that reads the environment, a file, or a socket.
4. Classloader isolation
When the class is demonstrably in the artifact and still not found, you have a visibility problem rather than a presence problem. App servers give every deployed application its own loader; Spring Boot's nested-jar layout uses a custom loader reading BOOT-INF/lib/*.jar without exploding them; OSGi enforces visibility per bundle wiring and will hide a package that is on disk simply because nothing exported it.
The recurring failure is a library calling Class.forName(name), which uses the caller's own defining loader — the library's, which cannot see your application classes. The convention is to route through the context loader instead:
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
Class<?> type = Class.forName(className, true, tccl);
This is a convention, not a guarantee. The context loader is whatever the surrounding framework set, it can be null, and on a thread from a pool it may be left over from whoever used that thread last. If you are writing the library, take a ClassLoader parameter and let the caller decide. That is more API surface and it is the only version that is actually correct.
And a JPMS note, because it produces a variant that looks identical and is not. A class on the module path whose package is not exported is present, loaded, and unreachable — surfacing as IllegalAccessError or a startup resolution error rather than NoClassDefFoundError. Everything on the classpath lands in the unnamed module, which reads every other module, so mixed setups behave inconsistently depending on which side a given jar ended up on. If a dependency moved between the two paths across environments, check that before anything else.
What is not a fix
Catching either of these to make the log quiet.
Catching ClassNotFoundException is legitimate in one shape only: the class is genuinely optional, the fallback is real, and you log at a level someone reads. An optional metrics exporter degrading to a no-op qualifies. A catch (ClassNotFoundException e) {} around a driver lookup does not — it converts a clear startup failure into an unexplained one twenty minutes later.
Catching NoClassDefFoundError is almost never right, and for the same reason that catching a ConcurrentModificationException is wrong: both report a defect rather than a condition in the world, and handling one means building on a fact that should never have been true. It is an Error because the JVM is telling you the deployment is broken, and continuing past that means running a process whose classpath cannot satisfy its own bytecode. The narrow exception is a deliberate capability probe at startup — testing whether an integration is present, once, with the result recorded. Never inside a request path, and never as a general safety net, because a catch (Throwable t) in a request handler will happily swallow "could not initialize class" ten thousand times and report each one as a failed request.
How to Prevent It
Test the artifact you ship, not the exploded classes. This is the single highest-value change, and it is the one most builds skip. Unit tests run against target/classes with the full test classpath, which is the one configuration guaranteed never to reproduce a packaging bug. Add one CI step that boots the packaged jar — java -jar, wait for a health endpoint, hit one real route, shut down. It catches every instance of cause 2 and most of cause 3, and it costs about forty seconds of pipeline time.
Keep static initialisers trivial and side-effect free. Constants, lookup tables built from literals, nothing that reads the environment or the filesystem or the network. Anything that can fail belongs in a method you call at a point where failure is reportable. The rule I use now: if a static initialiser can throw, it will, and it will do so in the environment where you have the least visibility.
Prefer explicit registration to Class.forName. If you control both ends, a map from key to constructor reference beats a map from key to class name in every way — the compiler checks it, refactoring renames it, and dead-code analysis knows the class is alive. Where you need real extensibility, ServiceLoader with a provides declaration gives you discovery without strings, and a missing provider becomes an empty iterator you can check rather than an exception you have to catch.
Pin versions and enforce it. Maven's maven-enforcer-plugin with requireUpperBoundDeps and banDuplicateClasses fails the build on the two transitive-dependency situations that produce linkage errors: a version older than something else demanded, and the same class present in two jars. Gradle's dependency locking does the equivalent. Ten minutes of setup, and it turns a runtime failure into a build failure.
Compile with -Xlint:all and read deprecation warnings as the schedule they are. A type deprecated for removal today is a NoClassDefFoundError two minor versions from now, and the warning is the only notice you get.
Reproducible builds close the loop. Same inputs, same bytes, verifiable by hash. When you can prove the jar in staging is the jar CI built, "it works on my machine" stops being a hypothesis you have to spend an hour eliminating.
Author's note: The behaviour described here reflects current LTS OpenJDK builds, and the traces are written to match what those actually print. Line numbers inside java.base frames shift between builds, so match on frame names rather than numbers. The erroneous-class marking and the two NoClassDefFoundError messages are specified in the JLS and JVMS; the exact message wording, the BuiltinClassLoader frame names, and the output format of -verbose:class and jcmd VM.classloader_stats are implementation details of the reference implementation and have changed before. Do not parse them in tooling.
Frequently Asked Questions
Is NoClassDefFoundError the same as ClassNotFoundException?
No. ClassNotFoundException is a checked exception under ReflectiveOperationException, thrown when your code asks for a class by name at runtime through Class.forName or ClassLoader.loadClass and no loader can produce it. NoClassDefFoundError is an Error under LinkageError, thrown when the JVM tries to link a symbolic reference that the compiler had already resolved against a class present at compile time. The practical difference is where you look: a CNFE points at the string your code passed in, a NCDFE points at the gap between your build classpath and your runtime classpath. They do appear together — the linking machinery wraps the loader's CNFE in a NCDFE — and the Caused by line tells you when that happened.
What does "Could not initialize class" actually mean?
It means the class was found and is not missing at all. It was located, loaded and linked; then its static initialiser ran and threw, and the JVM permanently marked the class erroneous for that defining loader. The first thread to touch it received an ExceptionInInitializerError with the real cause attached. Every touch after that gets NoClassDefFoundError: Could not initialize class with no cause and no <clinit> frame. If that is the message in front of you, the diagnostic you need is the earlier ExceptionInInitializerError further up the same log — possibly minutes earlier, on a different thread, naming a different class.
Should I ever catch NoClassDefFoundError?
Almost never. It is an Error rather than an Exception because the JVM is reporting that the deployment is broken, not that a runtime condition occurred, and swallowing it leaves the process running on a classpath that cannot satisfy its own bytecode. The one defensible use is a capability probe at startup, where the fallback is real and the failure is logged loudly — and even then, once, not per request. Catching ClassNotFoundException is more often legitimate, because a plugin or driver lookup on a user-supplied name genuinely can fail, but only where there is a real fallback rather than an empty catch block.
Why does it work in my IDE but fail in the jar?
Because the IDE runs against exploded class directories and the fully resolved dependency graph, including everything your build marked provided or test. The packaged artifact contains only what the packaging step chose to include. Shade and assembly plugins drop or relocate packages, provided means a container is expected to supply the jar and your container may not, and nested-jar layouts such as Spring Boot's use a custom loader with different visibility rules than a flat classpath. Running jar tf on the artifact and java -verbose:class on the failing process distinguishes those three in about a minute.
Does the module path change any of this?
It changes the failure mode more than the cause. Under JPMS a class can be present, loaded, and still unreachable because its package is not exported — that surfaces as an IllegalAccessError, or as a resolution failure at startup that names the module rather than the class. Missing modules are caught at resolution time, which is genuinely better than discovering them on the first request. But everything on the classpath lands in the unnamed module, which reads every other module, so a mixed classpath-and-module-path setup behaves inconsistently depending on which side each jar ended up on. When a class-loading failure appears only in one environment, checking whether a dependency moved between the two paths is worth doing before anything else.