NullPointerException is the most common runtime error in Java, nicknamed 'The Billion Dollar Mistake' by Tony Hoare (who invented null). Poor error handling is the second biggest source of production outages. This guide teaches you to handle both systematically — so your code is robust, readable, and debuggable.


Step 1 — Java Exception Hierarchy

The Exception Tree

  • Throwable: root of all exceptions and errors.
  • Error: serious JVM problems. NEVER catch these: OutOfMemoryError, StackOverflowError, VirtualMachineError.
  • Exception: recoverable problems your program should handle.
  • RuntimeException (unchecked): programming mistakes — NullPointerException, ArrayIndexOutOfBoundsException, ClassCastException, IllegalArgumentException, IllegalStateException.
  • Checked Exceptions: must be declared (throws) or caught — IOException, SQLException, ParseException, FileNotFoundException.
  • Rule: catch the MOST specific exception type first. Never catch Exception or Throwable without a very good reason.
ExceptionHierarchy.javajava
import java.io.*;
import java.sql.*;

public class ExceptionHierarchy {
    public static void main(String[] args) {

        // ============================================================
        // Basic try-catch-finally
        // ============================================================
        try {
            int[] arr = {1, 2, 3};
            System.out.println(arr[10]); // throws ArrayIndexOutOfBoundsException
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("Index out of bounds: " + e.getMessage());
        } finally {
            // finally ALWAYS executes: cleanup resources, close connections
            System.out.println("This always runs");
        }

        // ============================================================
        // Multi-catch: handle multiple exception types
        // ============================================================
        try {
            String s = null;
            s.length(); // NullPointerException
        } catch (NullPointerException | IllegalArgumentException e) {
            // Pipe | catches multiple types in one block
            System.out.println("Caught: " + e.getClass().getSimpleName());
        }

        // ============================================================
        // Exception chaining: preserve original cause
        // ============================================================
        try {
            doSomeWork();
        } catch (ServiceException e) {
            System.out.println("Service error: " + e.getMessage());
            System.out.println("Root cause: " + e.getCause()); // original exception
        }
    }

    static void doSomeWork() throws ServiceException {
        try {
            // Simulated low-level failure
            throw new IOException("DB connection refused");
        } catch (IOException e) {
            // Wrap in domain exception — preserve cause for debugging!
            throw new ServiceException("Failed to process data", e); // e is the 'cause'
        }
    }
}

class ServiceException extends RuntimeException {
    public ServiceException(String message, Throwable cause) {
        super(message, cause); // super stores the cause
    }
}

Step 2 — Checked vs Unchecked Exceptions: The Decision

When to Use Checked Exceptions

  • Use for RECOVERABLE conditions: the caller can reasonably handle them.
  • Examples: FileNotFoundException (try a different path), SQLException (retry query), ParseException (ask user to re-enter).
  • Force the caller to deal with the problem — they must catch or declare throws.
  • Problem: they clutter APIs and often lead to bad 'catch and ignore' code.

When to Use Unchecked (RuntimeException)

  • Use for PROGRAMMING ERRORS: bugs that should be fixed, not caught.
  • Examples: NullPointerException, IllegalArgumentException, IllegalStateException, IndexOutOfBoundsException.
  • Modern Java style (Spring, Hibernate, JPA) strongly prefers unchecked exceptions.
  • Don't declare 'throws' for every possible exception — it pollutes method signatures.
CustomExceptions.java — Domain Exception Hierarchyjava
// Build a hierarchy of custom exceptions for your domain

// Base exception for all application errors
public class AppException extends RuntimeException {
    private final String errorCode;

    public AppException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public AppException(String errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public String getErrorCode() { return errorCode; }
}

// Specific domain exceptions
class ResourceNotFoundException extends AppException {
    public ResourceNotFoundException(String resource, Object id) {
        super("RESOURCE_NOT_FOUND",
              resource + " not found with id: " + id);
    }
}

class DuplicateResourceException extends AppException {
    public DuplicateResourceException(String resource, String field, Object value) {
        super("DUPLICATE_RESOURCE",
              resource + " already exists with " + field + "=" + value);
    }
}

class InvalidOperationException extends AppException {
    public InvalidOperationException(String message) {
        super("INVALID_OPERATION", message);
    }
}

class ExternalServiceException extends AppException {
    private final String serviceName;

    public ExternalServiceException(String serviceName, Throwable cause) {
        super("EXTERNAL_SERVICE_ERROR",
              "External service '" + serviceName + "' failed", cause);
        this.serviceName = serviceName;
    }

    public String getServiceName() { return serviceName; }
}

Step 3 — try-with-resources: Automatic Resource Management

Resources like files, database connections, and network sockets MUST be closed after use. Failing to close them causes resource leaks that crash your application under load. try-with-resources (Java 7+) guarantees closure automatically.

TryWithResources.javajava
import java.io.*;
import java.sql.*;
import java.nio.file.*;

public class TryWithResources {

    // BAD: manual resource management — easy to forget, fails if exception occurs
    static void badWay() {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader("data.txt"));
            String line = reader.readLine();
            System.out.println(line);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (reader != null) { // must null-check!
                try {
                    reader.close(); // close() can also throw IOException!
                } catch (IOException e) {
                    e.printStackTrace(); // swallows original exception!
                }
            }
        }
    }

    // GOOD: try-with-resources — ANY AutoCloseable is closed automatically
    static void goodWay() {
        try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            System.err.println("Failed to read file: " + e.getMessage());
        }
        // reader.close() is ALWAYS called automatically — even if exception thrown!
    }

    // Multiple resources: closed in REVERSE order of declaration
    static void multipleResources(String dbUrl) throws Exception {
        try (Connection conn = DriverManager.getConnection(dbUrl);
             PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users");
             ResultSet rs = stmt.executeQuery()) {

            while (rs.next()) {
                System.out.println(rs.getString("name"));
            }
            // rs, stmt, conn closed in reverse order automatically
        }
    }

    // Custom AutoCloseable
    static class DatabaseOperation implements AutoCloseable {
        public DatabaseOperation() { System.out.println("Opening DB connection"); }

        public void execute() { System.out.println("Executing operation"); }

        @Override
        public void close() {
            System.out.println("Closing DB connection"); // called by try-with-resources
        }
    }

    // Modern file I/O with NIO (no manual close needed for Files utility methods)
    static void modernFileRead() throws IOException {
        Path path = Path.of("data.txt");

        // Read all lines as List<String> — whole file in memory
        java.util.List<String> lines = Files.readAllLines(path);

        // Read as stream — lazy, one line at a time (good for large files)
        try (var stream = Files.lines(path)) {
            stream.filter(line -> line.contains("ERROR"))
                  .forEach(System.out::println);
        } // stream.close() called automatically

        // Write to file
        Files.writeString(path, "Hello, World!",
            StandardOpenOption.CREATE, StandardOpenOption.APPEND);
    }
}

Step 4 — NullPointerException: Root Causes and Fixes

NPE occurs when you call a method or access a field on a null reference. Java 14+ gives 'Helpful NullPointerExceptions' that tell you exactly which variable was null. But better than debugging NPEs is preventing them.

NPECauses.java — Every way to get a NPEjava
public class NPECauses {
    static String name = null;
    String instanceField = null;

    public static void main(String[] args) {
        NPECauses obj = null;

        // 1. Calling method on null reference
        // name.length(); // NPE: Cannot invoke String.length() on null 'name'

        // 2. Accessing field on null object
        // obj.instanceField; // NPE: Cannot read field 'instanceField' from null 'obj'

        // 3. Unboxing null Integer
        Integer nullInt = null;
        // int i = nullInt; // NPE: Cannot unbox null Integer

        // 4. Accessing null array
        int[] arr = null;
        // int x = arr[0]; // NPE

        // 5. Throwing null
        // throw null; // NPE

        // 6. Calling method on result of another method that returns null
        String result = getValue();
        // result.toUpperCase(); // NPE if getValue() returns null

        // 7. Collection methods returning null
        java.util.Map<String, String> map = new java.util.HashMap<>();
        String val = map.get("nonexistent"); // returns null!
        // val.length(); // NPE
    }

    static String getValue() { return null; }
}
NPEPrevention.java — The right defensive patternsjava
import java.util.*;

public class NPEPrevention {

    // === STRATEGY 1: Objects utility methods ===
    static void objectsUtils() {
        String s = null;

        // requireNonNull: throw immediately with clear message (for method params)
        void processName(String name) {
            Objects.requireNonNull(name, "name must not be null"); // fail fast!
            System.out.println(name.toUpperCase());
        }

        // requireNonNullElse: provide a default
        String result = Objects.requireNonNullElse(s, "default"); // "default"

        // requireNonNullElseGet: lazy default (supplier)
        String result2 = Objects.requireNonNullElseGet(s, () -> computeDefault());

        // isNull / nonNull: null checks without NullPointerException
        if (Objects.nonNull(s)) { s.toUpperCase(); } // safe
        System.out.println(Objects.isNull(s)); // true
    }

    // === STRATEGY 2: Null checks in method bodies ===
    static String safeToUpper(String input) {
        if (input == null) return ""; // or throw IllegalArgumentException
        return input.toUpperCase();
    }

    // === STRATEGY 3: Ternary operator ===
    static int safeLength(String s) {
        return s != null ? s.length() : 0;
    }

    // === STRATEGY 4: Optional (for return values) ===
    static Optional<String> findUser(int id) {
        if (id <= 0) return Optional.empty();
        return Optional.of("User-" + id);
    }

    static void useOptional() {
        findUser(1)
            .map(String::toUpperCase)
            .ifPresent(System.out::println);

        String name = findUser(-1).orElse("Anonymous");
    }

    // === STRATEGY 5: Map.getOrDefault() — never null from Map ===
    static void safeMapAccess() {
        Map<String, Integer> map = Map.of("a", 1, "b", 2);
        int value = map.getOrDefault("z", 0); // 0, not null
        System.out.println(value);
    }

    // === STRATEGY 6: Collections.empty methods — never return null ===
    // BAD: return null when no results
    static List<String> badGetUsers() {
        return null; // caller must null-check!
    }
    // GOOD: return empty collection
    static List<String> goodGetUsers() {
        return Collections.emptyList(); // caller can always iterate, never NPE
    }

    // === STRATEGY 7: String.equals — put literal first ===
    static void stringCompare(String input) {
        // BAD: if (input.equals("expected")) — NPE if input is null!
        // GOOD:
        if ("expected".equals(input)) { // literal first: no NPE even if input is null
            System.out.println("Match!");
        }
        // Even better: Objects.equals(input, "expected") — null-safe both ways
    }

    // === STRATEGY 8: @NonNull / @Nullable annotations ===
    // Use Jakarta annotations or JetBrains annotations to document nullability
    // IDEs and static analysis tools (SpotBugs, Error Prone) use these
    static @jakarta.annotation.Nonnull String getNonNullValue() {
        return "guaranteed non-null";
    }

    static String computeDefault() { return "computed"; }
}

Step 5 — Best Practices for Exception Handling

ExceptionBestPractices.javajava
import java.util.logging.Logger;

public class ExceptionBestPractices {
    private static final Logger log = Logger.getLogger(ExceptionBestPractices.class.getName());

    // === BAD PRACTICES TO AVOID ===

    // 1. NEVER swallow exceptions silently
    static void badSilentIgnore() {
        try {
            riskyOperation();
        } catch (Exception e) {
            // NEVER do this! Bug is hidden, application behaves incorrectly.
        }
    }

    // 2. NEVER catch Exception or Throwable generically (almost always wrong)
    static void badCatchAll() {
        try {
            riskyOperation();
        } catch (Exception e) { // catches NullPointerException, programming bugs!
            e.printStackTrace(); // at least log, but this is still too broad
        }
    }

    // 3. NEVER ignore the original cause
    static void badLoseCause() {
        try {
            riskyOperation();
        } catch (Exception e) {
            throw new RuntimeException("Failed"); // original cause LOST!
        }
    }

    // === GOOD PRACTICES ===

    // 1. Catch specific exceptions
    static void goodSpecificCatch() {
        try {
            riskyOperation();
        } catch (java.io.IOException e) {
            log.severe("I/O error in riskyOperation: " + e.getMessage());
            throw new ServiceException("IO_ERROR", "Storage unavailable", e);
        }
    }

    // 2. Always preserve the cause
    static void goodPreserveCause() {
        try {
            riskyOperation();
        } catch (Exception e) {
            throw new ServiceException("PROCESSING_ERROR", "Failed to process", e); // e is cause!
        }
    }

    // 3. Log at the right level — and only once!
    // Log the exception WHERE you handle it, not where you catch-and-rethrow.
    // If you rethrow, let the handler higher up log it.
    static void goodLogging() {
        try {
            riskyOperation();
        } catch (java.io.IOException e) {
            // Log here because we're HANDLING it (converting and not rethrowing IOException)
            log.severe("Failed to read data: " + e.getMessage());
            // Return fallback, don't rethrow
        }
    }

    // 4. Fail fast with guard clauses
    static void processUser(String name, int age) {
        // Use guard clauses at the top, not nested ifs
        Objects.requireNonNull(name, "name required");
        if (name.isBlank()) throw new IllegalArgumentException("name cannot be blank");
        if (age < 0 || age > 150) throw new IllegalArgumentException("invalid age: " + age);

        // Main logic with no null checks needed below
        System.out.println("Processing: " + name + ", age: " + age);
    }

    // 5. Return Result types for expected failures (Java 21+ pattern)
    sealed interface Result<T> permits Success, Failure {}
    record Success<T>(T value) implements Result<T> {}
    record Failure<T>(String errorCode, String message) implements Result<T> {}

    static Result<String> safeParseEmail(String input) {
        if (input == null || !input.contains("@"))
            return new Failure<>("INVALID_EMAIL", "Email must contain @");
        return new Success<>(input.toLowerCase());
    }

    static void useResult() {
        Result<String> result = safeParseEmail("bad-email");
        switch (result) {
            case Success<String> s  -> System.out.println("Valid: " + s.value());
            case Failure<String> f  -> System.out.println("Error: " + f.message());
        }
    }

    static void riskyOperation() throws java.io.IOException {}
}

Step 6 — Spring Boot Global Exception Handling in Production

ProductionExceptionHandler.javajava
import org.springframework.http.*;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.util.*;

@RestControllerAdvice
public class ProductionExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(ProductionExceptionHandler.class);

    // --- Validation failures: 400 ---
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(e ->
            fieldErrors.put(e.getField(), e.getDefaultMessage()));

        return buildError(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR",
            "Input validation failed", fieldErrors);
    }

    // --- Missing required parameter: 400 ---
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResponseEntity<Map<String, Object>> handleMissingParam(
            MissingServletRequestParameterException ex) {
        return buildError(HttpStatus.BAD_REQUEST, "MISSING_PARAMETER",
            "Required parameter '" + ex.getParameterName() + "' is missing", null);
    }

    // --- Wrong type for parameter: 400 ---
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<Map<String, Object>> handleTypeMismatch(
            MethodArgumentTypeMismatchException ex) {
        return buildError(HttpStatus.BAD_REQUEST, "TYPE_MISMATCH",
            "Parameter '" + ex.getName() + "' should be of type " +
            ex.getRequiredType().getSimpleName(), null);
    }

    // --- Malformed JSON body: 400 ---
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<Map<String, Object>> handleBadJson(
            HttpMessageNotReadableException ex) {
        return buildError(HttpStatus.BAD_REQUEST, "MALFORMED_JSON",
            "Request body contains malformed JSON", null);
    }

    // --- Resource not found: 404 ---
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(
            ResourceNotFoundException ex) {
        return buildError(HttpStatus.NOT_FOUND, ex.getErrorCode(),
            ex.getMessage(), null);
    }

    // --- Wrong HTTP method: 405 ---
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<Map<String, Object>> handleMethodNotAllowed(
            HttpRequestMethodNotSupportedException ex) {
        return buildError(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED",
            "HTTP method '" + ex.getMethod() + "' is not supported", null);
    }

    // --- All application domain exceptions ---
    @ExceptionHandler(AppException.class)
    public ResponseEntity<Map<String, Object>> handleAppException(AppException ex) {
        log.warn("Application exception: [{}] {}", ex.getErrorCode(), ex.getMessage());
        return buildError(HttpStatus.BAD_REQUEST, ex.getErrorCode(), ex.getMessage(), null);
    }

    // --- Catch-all: 500 ---
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {
        // Log the FULL stack trace server-side — essential for debugging
        log.error("Unexpected error: {}", ex.getMessage(), ex);
        // NEVER send stack trace to client — security risk!
        return buildError(HttpStatus.INTERNAL_SERVER_ERROR,
            "INTERNAL_ERROR", "An unexpected error occurred", null);
    }

    private ResponseEntity<Map<String, Object>> buildError(
            HttpStatus status, String code, String message, Object details) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("timestamp", Instant.now());
        body.put("status", status.value());
        body.put("error", code);
        body.put("message", message);
        if (details != null) body.put("details", details);
        return ResponseEntity.status(status).body(body);
    }
}