Posts

Java NullPointerException: Causes, Prevention, and JSpecify

Published

Catherine Edelveis

35

TLDR

  • The stack trace points out where a null was used, but not where it came from.
  • Null itself is not a billion-dollar mistake, but the code that treats nulls as valid values might be.
  • JSpecify annotations used together with a static analysis tool can help you find code smells that can cause an NPE.

The cost of NullPointerException

Let’s start with an example right away. Suppose you have a product catalog and you want products to be sorted by price:

record Product(
        String id,
        String name,
        BigDecimal price
) {}

var products = catalog.fetchProducts();
products.sort(Comparator.comparing(Product::price));

The code compiles and runs, and then suddenly — this day or in a week — the logs are hit by a NullPointerException:

java.lang.NullPointerException
    at java.base/java.math.BigDecimal.compareTo(...)
    at java.base/java.util.Comparator...

The BigDecimal.compareTo code looks perfectly legitimate, but somewhere in the catalog a product has no price, and the comparator stumbled upon it.

But where did the null come from? Here’s what makes NullPointerException one of the most hated exceptions. The problem is not necessarily on the line denoted by the stack trace. The price could be null because the external API may have sent it, or introduced by the Product mapper, or a faulty update operation. This is the cost of a NullPointerException: you know the place where a null was first used, but the code it appeared can be N layers away.

What is a NullPointerException?

null is a value that denotes the absence of an object reference. NullPointerException or NPE is a RuntimeException, which is thrown when the application attempts to use null where an object is required.

An NPE can be thrown when you, for example, try to

  • Call the instance method of a null object,
  • Access or modify a field of a null object,
  • Access an element of a null object as if it were an array,
  • Throw null as if it were a Throwable value, or
  • Boxing the null value into a primitive.

The unboxing is an interesting case, because it looks like you don’t call any method at all:

void printInt(Integer a) {
    int b = a;   // a.intValue() under the hood
}

In Java up to version 13, the JVM only printed out the method, filename, and line number that caused the NPE like this: NullPointerException at PricingService.java:84. But that wasn’t really helpful because there could be several dereferences at this line. Luckily, Java 14 came with JEP 358: Helpful NullPointerExceptions. In this JDK version, the messages from helpful NPEs can be shown with -XX:{+|-}ShowCodeDetailsInExceptionMessages, but they are shown by default starting with JDK 15:

Cannot invoke "BigDecimal.compareTo(...)"
because the return value of "Product.price()" is null

At least now we know where exactly the null was accessed. But even helpful NPEs don’t show us where the null appeared in the first place, and they are not supposed to. JEP 358 marks the identification of the code that produced the null as a non-goal.

But why doesn’t Java prevent the NPE from sneaking into the runtime? An attempt to modify a null value looks like something the compiler should complain about. So why doesn't it?

Why NullPointerException is not a compile-time exception in Java

Java is type-safe, which means that it prevents applications from performing operations on data of incorrect types at compile-time:

String name = "Alice";
name + 42;             // fine, string concatenation exists
name.saySomething();   // compile error

The program that contains the code like above will not start. An NPE should fall under the same rule, and yet the compiler will not complain if we do:

String name = customer.getName();   // suppose this hands us a null
int length = name.length();         // compiles

Is that a contradiction or a design flaw? I wouldn’t call it either. In this example, the compiler sees that name is declared String, and it knows that String has a length() method. So, calling name.length() is perfectly legal. But the compiler doesn’t know whether the name refers to a String or if it is a null. Java’s original type safety design did not encode this information. Rather, it allows that the null reference is assigned to any reference type. So String does not mean "a String," it means "a String, or null," with no information for the compiler to understand which one you meant.

It was a deliberate design back in the 90s when Java was developed, and null-safe type systems were not a standard practice. Modern languages such as Kotlin, Swift or Rust can handle nulls more gracefully. For example, Kotlin treats String and String? as two different types. String cannot be null, whereas String? can be. So, this code will fail at compile-time because we cannot dereference a value that may be null:

val name: String? = null
println(name.length) // compile-time error

But don’t get angry with Java ahead of time. The decision to treat null this way did not age well, this is true. But Java cannot simply take Kotlin’s null-safety feature. If it did, the meaning of every unannotated type in many applications and hundreds of libraries would suddenly change, and a lot of working code would stop compiling. Null-restricted and nullable types are a work in progress under the auspices of Project Valhalla. But it is still a JEP draft, with no number or target release.

Is null always bad?

The question you may have at this point is: maybe we should stop using null at all? After all, even Sir Tony Hoare, the inventor of the null reference in 1965, called it his billion-dollar mistake at QCon London in 2009!

I won't argue with Sir Hoare. I'll just carefully smooth the edges.

We live with null reference now, it’s a fact pointless to complain about. But maybe null is not always bad? Consider the following situation. You have a class User with a field middleName. A user can have no middle name, which is perfectly normal. The same goes for the absent profile picture or an active labor agreement without a termination date. SQL has NULL, JSON has absent members, and a repository lookup might have found nothing. I’m not saying that all these cases should be treated as null. But hunting null down in your code at all costs resembles a witch hunt. Besides, languages with null safety don’t ban null, they just implement guardrails around it.

So null itself is not a mistake, but the application design that lets treating null as a legitimate object is. That leads us to the next question.

What can we do about NullPointerException in Java?

At compile time, Java makes an assumption about the value type. And only at runtime — when the code gets executed — Java discovers whether the assumption is right or wrong. If it is wrong, the compiler throws an NPE. NPE itself is unchecked because for Java, this type of failure is usually a programming error, and it doesn’t force every caller to catch it.

Consequently, If your app throws a NullPointerException, something is obviously wrong. It gives us two rules, and which one you implement depends on whether the null is allowed to exist in a specific case.

Rule one: if null can't exist, don't handle it

Let’s go back to our price comparator. Suppose our business logic says a product price is never absent. It is tempting to add the null handling to the method directly:

Comparator.comparing(
        Product::price,
        Comparator.nullsLast(Comparator.naturalOrder())
);

That would be a mistake. Although sorting now works, we have taught our codebase that a priceless product is acceptable, and swept the issue under the rug. It will return one day and bite you even harder. So never use this as a fix for a null that shouldn't be there!

The only solution is to find the bug. Suppose we have a method that calculates a discount. In our case, this method returned null when there was no discount, instead of keeping the price unchanged:

BigDecimal finalPrice =
        discount != null
                ? product.price().subtract(discount)
                : null;                  // here is the bug

We found the bug and fixed it — problem solved. What else can we do to prevent the null from sneaking into the system?

We can also prevent the null from being accepted from the external API with Jakarta Validation annotations:

record ProductRequest(
        String id,
        @NotNull String name,
        @NotNull BigDecimal price
) {}

And we can also make the field NOT NULL on the database level:

CREATE TABLE products (
    id    BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name  VARCHAR(255) NOT NULL,
    price NUMERIC      NOT NULL
);

The golden rule: if the null is impossible according to the contract, do not try to handle it downstream. Instead, prevent it in the first place or reject it where it enters the system.

Rule two: if null is legitimate, decide what it means

What if by our business logic, the absence of a value is legitimate? That gives us rule number two: If the absence of the value is legitimate, you must explicitly define how it should be handled.

You have several feasible options, and again, the choice depends on a specific situation.

For example, a user has an optional nickname and leaves the field blank when registering. We can assign a default value to it, for example a username:

String nickname =
    userRequest.nickname != null ? userRequest.nickname : userRequest.username;

We can initialize the object:

class User {
    List<Comment> comments = new ArrayList<>();
    double postKarma = 0.0;
}

We can also make the possibility of null explicit in the API with the Optional class. It can be used both for the optional field of an object and for cases where null means not found, like after a repository lookup (although in this case, we may also want a @NotNull guardrail in case the method returns a null instead of Optional):

@NotNull Optional<@NotNull User> findUser(long id);

Note that Optional does not protect you from an NPE. What it does is let you make the possible absence of a value part of the contract and handle it accordingly.

Another option is to keep the null and handle it explicitly:

if (email != null) {
    sendEmail(email);
}

Back to our price comparator one more time. This time, the contract allows for a null price. In this case, Comparator.nullsLast becomes a legitimate solution. Just don’t go on a spree of adding null checks everywhere. That’s a bad pattern that makes you get used to swallowing NPEs. So, whenever you encounter an NPE, ask yourself:

  • Why is this value null?
  • Is null valid here?
  • If not, where should it have been rejected?
  • If yes, where should the absence have been explicitly handled?

Still, running into NPEs in production sounds like something that we should avoid. Is there a way to catch them earlier? Yes, and that’s where JSpecify comes into play.

What is JSpecify

Take a look at the signature of this method: BigDecimal findDiscount(String productId). Does it return only a BigDecimal, or a BigDecimal or null? The compiler doesn’t know. It would help a lot if Java knew about our contract, and one day it will! Meanwhile, we can make use of JSpecify and static analysis tools.

JSpecify is an open source specification that defines a common set of annotations to be used in JVM languages to improve static analysis and language interoperation. Its current focus is on defining annotations to express nullness information in Java in a standard way.

JSpecify consists of two parts: an annotation library and a specification that defines what these annotations mean, so that different tools don’t interpret one annotation differently.

Note that JSpecify is not a JDK feature nor is it a part of the Java language. No Java release requires JSpecify. It is an independent library-neutral project, which supports Java 8 and later, and which you can add to your project as a dependency.

If you are on Java 8, there’s one caveat worth mentioning, though. JDK 8's javac crashes with an AssertionError if it sees @NullMarked on a method or constructor (JDK-8295314, still open). Therefore, you should build the app with the newest javac you have and use --release 8 to preserve compatibility. See JSpecify’s wiki for more information on version compatibility.

JSpecify annotations

JSpecify library offers four annotations:

  • @Nullable says a value of this type can be null;
  • @NonNull says the value cannot be null;
  • @NullMarked applies to a class, package, or module and makes everything inside it non-null by default. This way, you can avoid adding @NonNull everywhere;
  • @NullUnmarked can be applied to code within @NullMarked-annotated package that you haven’t annotated yet, so that unannotated types have unspecified nullness. This annotation can help with gradual adoption of JSpecify.

We will see how to use these annotations in the following sections.

How JSpecify helps to deal with NullPointerException

The important part we need to make clear — JSpecify does not prevent NullPointerException. According to JSpecify annotations Javadoc, annotations have “well-defined semantic meaning, but no direct consequences.” It means that even if you add an annotation and violate the contract somewhere in the code, the compiler won’t complain, and the code will compile and run:

@NullMarked
class PricingService {

    @Nullable BigDecimal findDiscount() {
        return null;
    }

    BigDecimal calculate() {
        return findDiscount().add(BigDecimal.TEN);   // compiles
    }
}

To turn this NPE-bearing bug into an error and catch it at compile time, you need a JSpecify-aware static analysis tool. This tool reads the contract, finds the unsafe operations, and reports them as warnings or errors depending on how you configure it.

Five tools support JSpecify to some degree and are listed in JSpecify’s docs:

  • EISOP Framework has good conformance, but there is a caveat around unspecified-nullness code.
  • NullAway supports JSpecify, but currently does not fully analyze generics.
  • IntelliJ IDEA supports JSpecify with some known issues, mainly around generics.
  • Checker Framework understands @Nullable and @NonNull, but not @NullMarked or @NullUnmarked.
  • JSpecify reference checker is mostly conformant, but explicitly not intended for production use.

On the bright side, if your Java code interoperates with Kotlin, you don’t need an additional tool. The Kotlin compiler understands JSpecify annotations and applies its own null safety logic. @Nullable and @NullMarked have been supported since Kotlin 1.8.20, and since 2.1.0 the resulting problems are errors by default.

How to use JSpecify with NullAway

Set up the project

The demo I will use in this tutorial is a small Spring Boot REST API that stores and retrieves network nodes. It uses Java 25, Maven and Spring Boot 4.1.1 with WebMVC, JDBC, H2, and Jakarta Validation.

The application has some nullable data, a custom Signal<T> generic, and an NPE bug I planted intentionally. Clone the repo, and from the main branch run this command to hit the bug:

curl -i http://localhost:8080/api/nodes/999

Add and configure JSpecify and NullAway

First, let’s add the required dependencies.

JSpecify annotations library is available on Maven Central. Jspecify docs recommend to not hide the annotations from consumers, and to keep them at runtime.. So, Gradle projects using java-library need api("org.jspecify:jspecify:1.0.1") instead of implementation, and Maven users are advised against provided or optional scope. The jar is very small by design, so this costs nothing.

<dependency>
    <groupId>org.jspecify</groupId>
    <artifactId>jspecify</artifactId>
    <version>1.0.1</version>
</dependency>

Now, we need to add a static analyzer for automated detection of nullness problems. I will use NullAway. It is an open source project that runs as an Error Prone plugin. So we must add them both to the compiler's annotation processor path with several arguments:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>${maven-compiler-plugin.version}</version>
    <configuration>
        <release>${java.version}</release>
        <showWarnings>true</showWarnings>
        <compilerArgs>
            <arg>-XDcompilePolicy=simple</arg>
            <arg>--should-stop=ifError=FLOW</arg>
            <arg>-Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:JSpecifyMode=true</arg>
        </compilerArgs>
        <annotationProcessorPaths>
            <path>
                <groupId>com.google.errorprone</groupId>
                <artifactId>error_prone_core</artifactId>
                <version>2.50.0</version>
            </path>
            <path>
                <groupId>com.uber.nullaway</groupId>
                <artifactId>nullaway</artifactId>
                <version>0.14.0</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

What these arguments give us:

  • -Xplugin:ErrorProne turns Error Prone on.
  • -XepDisableAllChecks disables Error Prone's normal collection of checks because we want to focus on nullness checks, not everything else.
  • -Xep:NullAway:ERROR turns NullAway on and makes the null findings compilation errors.
  • OnlyNullMarked=true means that NullAway only checks code inside @NullMarked scopes. Therefore, the application will compile even if it contains violations of null contracts if these violations are in an unmarked package, which is convenient for incremental migration. Once we add @NullMarked, NullAway begins checking that package.
  • JSpecifyMode=true Tells NullAway to use JSpecify semantics.

Note that Error Prone works through internal javac APIs. Since JDK 16, JEP 396 strongly encapsulates JDK internals, so we have to open them explicitly. A standard Maven build runs the compiler inside Maven's own JVM, so put the options in .mvn/jvm.config. The required options are specified in the ErrorProne installation guide, so you just copy and paste them from there.

--add-exports
jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports
jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports
jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports
jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports
jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports
jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports
jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports
jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens
jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens
jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED

There’s one constraint we need to consider. NullAway's JSpecify mode needs javac to read type-use annotations out of bytecode. That requires JDK 22 or newer, or the -XDaddTypeAnnotationsToSymbol=true flag on JDK 21.0.8+ and 17.0.19+. That flag isn't supported on Oracle JDK 17 or 21, so you need an OpenJDK build. My Java runtime of choice is Liberica JDK recommended by Spring.

The NullAway and JSpecify teams give the same recommendation. Compile the project with the newest JDK you can and use --release to target what you ship.

Add JSpecify annotations

Let's start with the domain package. We could annotate with @NonNull every field of the Node record that must not be null like that:

public record Node(
        @NotNull long id,
        @NotNull String codename,
        @NotNull String district,
        String alias,
        String operator,
        @NotNull NodeStatus status,
        @NotNull Instant lastSeen,
        Instant decommissionedAt
) {}

But that would get verbose very quickly. A better approach is to create a package-info.java in this module and annotate the whole package with @NullMarked:

@NullMarked
package dev.cyberjar.jspecify.domain;

import org.jspecify.annotations.NullMarked;

Everything inside becomes non-null by default, and we only annotate the exceptions. The database allows three columns to be null, so those three receive @Nullable:

public record Node(
        long id,
        String codename,
        String district,
        @Nullable String alias,
        @Nullable String operator,
        NodeStatus status,
        Instant lastSeen,
        @Nullable Instant decommissionedAt
) {}

Compile the demo now. You will notice that everything passes. This is because all the null mines are in packages we haven't marked yet, and OnlyNullMarked=true means NullAway isn't watching them.

Now, add a package-info.java to the repository module next. In NodeRepository, findById currently returns null:

public Node findById(long id) {
    return jdbc.sql("""
                    SELECT id, codename, district, alias, operator_name,
                           status, last_seen, decommissioned_at
                    FROM node
                    WHERE id = :id
                    """)
            .param("id", id)
            .query(this::mapNode)
            .optional()
            .orElse(null);
}

inside a @NullMarked package, it will make NullAway complain. If you try to compile the app now, you will get the error:

repository/NodeRepository.java:[27,9] [NullAway] returning @Nullable expression
from method with @NonNull return type

There are two solutions. You can rewrite the method so it never returns null, or admit that it does and add a @Nullable return type:

public @Nullable Node findById(long id) {
    return jdbc.sql("""
                        SELECT id, codename, district, alias, operator_name,
                               status, last_seen, decommissioned_at
                        FROM node
                        WHERE id = :id
                        """)
            .param("id", id)
            .query(this::mapNode)
            .optional()
            .orElse(null);
}

The decommissionedAt field of our Node can be null, so in the nullableInstant() method of the same class, where we convert Timestamp received from the database to the Instant, both input and output can be null, so add the @Nullable annotation there as well, both to the return type and the input parameter:

private @Nullable Instant nullableInstant(@Nullable Timestamp timestamp) {
    return timestamp == null ? null : timestamp.toInstant();
}

The same goes for the insert() method. As alias and operator of the Node can be null, we must add @Nullable to the relevant input parameters:

public Node insert(
        String codename,
        String district,
        @Nullable String alias,
        @Nullable String operator
) {
//see full method body on GitHub
}

After you are done with one package of the application, move on to the next. For example, in this demo, add a package-info.java to service, compile the app, and fix the issues that NullAway found. The same goes to the api module.

Note that in the RegisterNodeRequest record of the api module, Jakarta Validation and JSpecify can be used together because they have different tasks. Jakarta rejects bad input at runtime. JSpecify has nothing to do with runtime, its contract is for static analysis tools.

To the exciting part! In the beginning of the section on JSpecify I said that JSpecify cannot find NPE by itself, it needs a static analysis tool. We added both, so now we can detect bugs. In the controller, the node method returns a NodeResponse built from a repository lookup that can return null. But this method has no guard or check, resulting in the NPE from the curl above:

@GetMapping("/{id}")
public NodeResponse node(@PathVariable long id) {
    Node node = service.find(id);

    // Deliberate bug: find(id) can return null
    return NodeResponse.from(node);
}

Let’s see whether NullAway can spot it. Run maven clean compile — the build should fail with the following error:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project jspecify: Compilation failure
[ERROR] /Users/catherine/projects/jspecify/src/main/java/dev/cyberjar/jspecify/api/NodeController.java:[41,34] [NullAway] passing @Nullable parameter 'node' where @NonNull is required

It proves that static analysis found the bug and directed us to the exact class and line it is hiding on. We added @NullMarked to the module, and a rebuild turned a production bug into a compilation error. That is the whole point of our efforts.

The rest is the usual drill. Rewrite the code by setting the guardrails:

@GetMapping("/{id}")
public NodeResponse node(@PathVariable long id) {
    Node node = service.find(id);

    if (node == null) {
        throw new ResponseStatusException(
                HttpStatus.NOT_FOUND,
                "Node not found"
        );
    }
    return NodeResponse.from(node);
}

The compilation error is no more, just like the NPE.

Handling Generics with JSpecify

I also want to show you how to deal with generics when using JSpecify because it may seem tricky at first. This is because with generics, we need to ask an additional question: is the type argument itself allowed to be null?

The demo has a Signal<T> to demonstrate it:

public record Signal<T>(String channel, T payload) {}

The package is @NullMarked, so neither the Signal nor its payload can be null. Suppose we need a signal whose payload can be absent. If we add @Nullable to Signal (@Nullable Signal<T>), it can be null. If we add @Nullable to T — that’s where things get interesting. Java treats T as <T extends Object>. So, as our package is @NullMarked, Jspecify sees <T extends @NonNull Object> . It means that T must be a non-null type. So, if we want to specify that the type argument of Signal can be null, we cannot simply write Signal<@Nullable String>, because @Nullable String does not satisfy the non-null bound of T.

So what do we do? We unfold the declaration and add @Nullable to object:

public record Signal<T extends @Nullable Object>(String channel, T payload) {}

This way, we can use nullable or non-null types for T and cover three possible situations in our app:

Signal<@Nullable String> publicAlias;   // may be absent
Signal<@Nullable String> operator;      // may be absent
Signal<String>           status;        // always present

Why not just write @Nullable T payload? Because that would mean every Signal permits a null payload, including the ones where it must never be.

There is one edge case left to cover. Suppose a payload value is usually nullable, but a certain operation requires it to be present. We can cover this logic by adding a method requirePayload() with NonNull T as a return value. So, the method returns a non-null value or throws:

public @NonNull T requirePayload() {
    if (payload == null) {
        throw new IllegalStateException("Signal " + channel + " has no payload");
    }
    return payload;
}

If somewhere in the code you need to force a nullable payload to have a value, you can use this method:

String operator = signal.requirePayload();

JSpecify limitations

JSpecify itself doesn't impose much in terms of limitations, but rather the ecosystem around it.

  • Not any static analyzer supports JSpecify.
  • JSpecify can express nullness for generics precisely, but some analyzers still have imperfect support for JSpecify generics, inference, wildcards, and related cases.
  • JSpecify's migration guide states that there are cases where analysis cannot prove whether the code is safe even when you know it is. This is called false positives, and they are not specific to JSpecify or tools that support it. Those cases may require suppressions.
  • Older javac versions can cause trouble with some annotation processors that read nullness from the classpath, because type-use annotations weren't read correctly from class files until JDK 22. So the newer your JDK, the better.

Migration to JSpecify

The process for migrating an existing application is the same as I described for the demo. Annotate one package, compile, fix errors, move on to the next.

It is recommended to select one class or package with few dependencies, annotate the places where null is allowed with @Nullable, then use @NullMarked for the package so everything else becomes non-null. If part of it isn't ready, you can use @NullUnmarked, just don’t let it sit there forever. When you are done with annotating a part of the code, run your analyzer. If you encounter any issues, fix them. For example, you made something @Nullable when it should be @NonNull or vice versa. Or maybe you have a bug that is an NPE mine — then fix it. That’s what you are doing all that for! In rare cases, the static analysis may issue false positives. In that case, you can add SuppressedWarnings annotation.

If you already use nullness annotations from another project, the worst possible first move is deleting them all. Different annotation sets carry different semantics. The JSR-305 array case is a good example. Under javax.annotation, @Nullable Object[] means a nullable array of non-null elements. However, JSpecify reads the identical syntax the other way around, as a non-null array whose elements may be null. To keep the original meaning you have to move the annotation:

Object @Nullable [] items;

Review the docs for both libraries carefully and migrate step by step.

Conclusion: treating the disease, not the symptom

An NPE is a symptom, and we don't treat symptoms. We treat a disease. The disease is a null with no agreed meaning or correct handling.

So if your application throws an NPE, ask yourself whether null is allowed in this place, and act accordingly. Every null that is allowed in your program should have a meaning, and the code that may receive this null should know this meaning and deal with it correctly.

JSpecify coupled with a static analysis tool can help you accomplish this task. Java will one day let you use its own null-restricted and nullable types. But you can start using JSpecify now, and adoption of Java’s native null markers will be much easier.

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading