Home › Guides › Java
Java10 min readCode verified

Strings: substring, equals vs ==, and StringBuilder

Strings are the first objects most Java students use every day, and they break three rules people assume without noticing: a String can never change, == does not compare text, and substring stops one short. This guide covers the methods, how to compare text properly, and StringBuilder, the String you are allowed to edit. Every listing here was compiled and run on OpenJDK 21.

Printed letters#

Think of a name tag made on a label printer. Once the letters are printed, you cannot rub one out and change it. If you want a different name, you print a new label, and the old one is still exactly what it was.

A Java String is that label. Once a String exists, its characters never change. The word for this is immutable, which just means “cannot be changed”. Every String method that seems to change the text actually prints you a new label and hands it back. If you do not pick it up, nothing happened.

String is a class, not a primitive, which is why its name starts with a capital letter and why it has methods. A String variable holds a reference: an arrow pointing to the String object, rather than the text itself. That one fact explains the == trap later on.

Positions start at zero#

Every character has a position, called its index, and the first index is 0.

index012345
charSaturn
public class Index {
    public static void main(String[] args) {
        String planet = "Saturn";
        System.out.println(planet.length());
        System.out.println(planet.charAt(0));
        System.out.println(planet.charAt(2));
        System.out.println(planet.charAt(planet.length() - 1));
    }
}
6
S
t
n

“Saturn” has length 6, so its indexes run from 0 to 5. The last character is always at length() - 1. Asking for charAt(6) goes past the end, and the program stops with a StringIndexOutOfBoundsException while it runs. The compiler cannot catch it, because it does not know how long the String will be.

charAt returns a char, a single character in single quotes like 'S', not a one-letter String.

substring stops one short#

substring cuts out a piece and returns it as a new String. It comes in two forms:

CallGives you
s.substring(begin)from begin to the end
s.substring(begin, end)from begin up to, but not including, end
public class Slice {
    public static void main(String[] args) {
        String file = "report_final.pdf";
        System.out.println(file.substring(7));
        System.out.println(file.substring(0, 6));
        System.out.println(file.substring(7, 12));
        System.out.println("[" + file.substring(3, 3) + "]");
    }
}
final.pdf
report
final
[]

The end index is never included. The quick check is that the length of the piece is end - begin: substring(7, 12) is 5 characters long. When begin and end are equal, the piece has length 0, which is the empty String, "". That is perfectly legal.

Finding text: indexOf and lastIndexOf#

indexOf searches for a character or a piece of text and tells you the index where the first match starts. lastIndexOf does the same from the other end. If there is no match, both return -1, which can never be a real position.

public class Find {
    public static void main(String[] args) {
        String path = "home/docs/notes/docs.txt";
        System.out.println(path.indexOf("docs"));
        System.out.println(path.lastIndexOf("docs"));
        System.out.println(path.indexOf('/'));
        System.out.println(path.indexOf('/', 5));
        System.out.println(path.indexOf("music"));
        int dot = path.lastIndexOf('.');
        System.out.println(path.substring(dot + 1));
    }
}
5
16
4
9
-1
txt

“docs” appears twice: first at index 5, last at index 16. indexOf('/', 5) starts looking at index 5, so it skips the first slash and finds the next one at 9. And the last two lines show the usual partnership: find a position with indexOf, then cut with substring. Here that pulls out the file extension.

Methods hand back a new String#

This is the bug that looks like Java ignoring you:

public class Stuck {
    public static void main(String[] args) {
        String city = "savannah";
        city.toUpperCase();
        city.replace('a', 'o');
        System.out.println(city);
        city = city.toUpperCase();
        System.out.println(city);
        String tidy = "   Columbus  ".trim();
        System.out.println("[" + tidy + "] " + tidy.length());
    }
}
savannah
SAVANNAH
[Columbus] 8

The first println still shows savannah. toUpperCase() and replace() each built a brand new String and handed it back, and this code dropped both on the floor. city still points at the original.

The fix is to catch the new String: city = city.toUpperCase();. The variable now points at the new label. trim() works the same way, returning a copy with the spaces removed from both ends.

MethodReturns
toUpperCase(), toLowerCase()a new String with the case changed
trim()a new String without leading and trailing spaces
replace(old, new)a new String with every match replaced
concat(other)a new String with other joined on the end, like +

Joining text with +#

+ means two different things. Between two numbers it adds. As soon as a String is involved, it joins. Java works left to right, so where the String appears changes everything:

public class Join {
    public static void main(String[] args) {
        System.out.println("Total: " + 3 + 4);
        System.out.println("Total: " + (3 + 4));
        System.out.println(3 + 4 + " total");
        System.out.println('a' + 'b' + "!");
        System.out.println("" + 'a' + 'b' + "!");
    }
}
Total: 34
Total: 7
7 total
195!
ab!
  • "Total: " + 3 + 4 joins the 3, and then joins the 4: Total: 34.
  • Brackets make the addition happen first: Total: 7.
  • 3 + 4 + " total" adds before any String appears: 7 total.
  • 'a' + 'b' is two chars, and chars are numbers underneath (97 and 98), so they are added: 195!. Starting with "" makes it text from the first step.

equals vs ==#

Here is the rule, and then the reason. To compare the text of two Strings, use equals. Never use ==.

public class Same {
    public static void main(String[] args) {
        String a = "Java";
        String b = "Javascript".substring(0, 4);
        System.out.println(a + " and " + b);
        System.out.println(a == b);
        System.out.println(a.equals(b));
        System.out.println("java".equals(a));
        System.out.println("java".equalsIgnoreCase(a));
    }
}
Java and Java
false
true
false
true

Both variables print as Java, yet a == b is false. That is because == compares the references: it asks “are these two arrows pointing at the very same object?”. substring built a brand new object at run time, so there are two separate objects that happen to hold the same letters. equals compares the characters one by one, which is what you meant.

It is like two copies of the same book. Same words, different books. equals asks about the words; == asks whether it is the same physical book.

This bites hardest with input. Text a user types into a Scanner is always a new object, so if (answer == "yes") is false even when they typed yes. equals is also case-sensitive; equalsIgnoreCase is the version that is not. Writing the literal first, "yes".equals(answer), has a bonus: it cannot crash even if answer is null.

compareTo: which comes first#

equals answers “same or not?”. compareTo answers “which comes first?”, and it returns a number rather than a boolean. Read a.compareTo(b) as “a minus b”:

ResultMeans
negativea comes before b
zerothey are equal
positivea comes after b
public class Order {
    public static void main(String[] args) {
        System.out.println("apple".compareTo("banana"));
        System.out.println("banana".compareTo("apple"));
        System.out.println("pear".compareTo("pear"));
        System.out.println("car".compareTo("cart"));
        System.out.println("Zebra".compareTo("apple"));
        String first = "Omar", second = "Aisha";
        if (first.compareTo(second) < 0) {
            System.out.println(first + " comes first");
        } else {
            System.out.println(second + " comes first");
        }
    }
}
-1
1
0
-1
-7
Aisha comes first

The exact numbers are character differences, but only the sign matters, so always test with < 0, == 0 or > 0. Two details are examined often:

  • When one String is the start of the other, as with car and cart, the shorter one comes first.
  • Every uppercase letter comes before every lowercase letter, because of the numbers behind the characters: 'Z' is 90 and 'a' is 97. So "Zebra" sorts before "apple". Use compareToIgnoreCase if that is not what you want.

Java will not let you write first < second for Strings. compareTo is the only way to order them.

StringBuilder: the String you can edit#

If a String is a printed label, a StringBuilder is a whiteboard. You can add to it, insert into it, rub bits out and reverse it, and it is still the same whiteboard. It is mutable, the opposite of immutable.

public class Builder {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder("tea");
        sb.append("pot");
        System.out.println(sb);
        sb.insert(0, "big ");
        System.out.println(sb);
        sb.reverse();
        System.out.println(sb);
        sb.reverse().setCharAt(0, 'B');
        System.out.println(sb + " (" + sb.length() + " chars)");
        String done = sb.toString();
        System.out.println(done.toUpperCase());
    }
}
teapot
big teapot
topaet gib
Big teapot (10 chars)
BIG TEAPOT

None of those calls needed sb = ... in front, because each one changed sb itself. Each method also returns the same builder, which is what lets calls be chained, as in sb.reverse().setCharAt(0, 'B').

A StringBuilder is not a String. When you need a String, for equals or a String method or a variable of type String, call toString(). Skip it and the compiler says so:

public class Mixup {
    public static void main(String[] args) {
        String s = new StringBuilder("ab");
    }
}
Mixup.java:3: error: incompatible types: StringBuilder cannot be converted to String
        String s = new StringBuilder("ab");
                   ^
1 error
StringStringBuilderStringBuffer
can be changednoyesyes
safe for several threads at onceyesnoyes (synchronized)
speed when changingslow: a new object every timefastesta little slower

StringBuffer has the same methods as StringBuilder. It adds locking so that several threads can share one safely, and it pays a small cost for that. In ordinary single-threaded programs, which is every program in this course, use StringBuilder.

Building text in a loop#

The place a StringBuilder really earns its keep is a loop that builds up text:

public class Loop {
    public static void main(String[] args) {
        StringBuilder row = new StringBuilder();
        for (int i = 1; i <= 5; i++) {
            row.append(i);
            if (i < 5) {
                row.append(", ");
            }
        }
        System.out.println(row);
    }
}
1, 2, 3, 4, 5

With a plain String, every += would copy all the text so far into a brand new object, and a thousand passes would make a thousand throw-away Strings. The StringBuilder keeps adding to one object. Build with a StringBuilder, then call toString() once at the end if you need a String.

One sentence to carry into the exam

Every String method hands you back a new String, and if you did not catch it, nothing happened. Compare text with equals, order it with compareTo, remember substring's end is never included, and reach for StringBuilder when the text needs to change.

Where these go wrong#

  • Calling s.toUpperCase(); on its own line. The result is thrown away. Write s = s.toUpperCase();.
  • Comparing Strings with ==. It compares objects, not text. Use equals.
  • Expecting substring(a, b) to include index b. It stops one short.
  • Using charAt(s.length()). The last index is length() - 1.
  • Reading a compareTo result as true or false. It is an int. Test its sign.
  • Forgetting that uppercase sorts first. "Zebra" comes before "apple".
  • Treating a StringBuilder as a String. Call toString().
  • Comparing two StringBuilders with equals. StringBuilder does not compare contents that way; compare their toString() results.
  • "Total: " + a + b joins the digits. Bracket the sum.

Test yourself in the free Kestrel Exams app

Topic-selectable practice — offline, no ads, no account.

Practice Strings →

Frequently asked questions#

Why does == not work to compare Strings in Java?

Because == compares references: it checks whether two variables point to the very same object. Two separate String objects can hold identical text, for example one read from a Scanner and one written in the code, and == calls them different. equals compares the characters themselves, which is what you almost always want.

Does substring include the end index?

No. s.substring(begin, end) includes the character at begin and stops just before end, so the result has end - begin characters. s.substring(begin) with one argument runs from begin to the end of the String.

Why does toUpperCase not change my String?

Strings are immutable: no method can change one. toUpperCase builds and returns a new String. If you call it without storing the result, as in city.toUpperCase();, the new String is thrown away. Write city = city.toUpperCase(); to keep it.

What does compareTo return in Java?

An int. It is negative if the calling String comes before the argument, zero if they are equal, and positive if it comes after. Test the sign, not the exact value. Uppercase letters sort before all lowercase letters, and when one String is the start of the other, the shorter one comes first.

What is the difference between String and StringBuilder?

A String can never be changed; every method returns a new String. A StringBuilder can be changed in place with append, insert, reverse and similar methods, which makes it much faster for building text in a loop. Call toString() to get a String from it.

What is the difference between StringBuilder and StringBuffer?

They have the same methods. StringBuffer is synchronized, meaning it is safe for several threads to share, and it is a little slower as a result. StringBuilder is not synchronized and is the usual choice in single-threaded code.

What does indexOf return if the text is not found?

-1. Every real index is 0 or more, so -1 can only mean 'not found'. lastIndexOf also returns -1 when there is no match.

Suggest a change

Something here not clear? A topic you wish we covered? Tell us. We read every message, and a request is the fastest way to get a guide written — several of these exist because somebody asked.