Free Handbook · Every example compiled & verified

Strings & Arrays

Immutable strings and their methods, StringBuilder, verbatim and raw literals, ordinal comparison, then arrays, ranges, indices and Span<T>.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 03 · what you'll be able to do

  • Explain why strings are immutable and use the common string methods without expecting them to change the original
  • Build text in a loop with StringBuilder, and write paths and JSON with verbatim and raw string literals
  • Compare strings deterministically with StringComparison.Ordinal and OrdinalIgnoreCase
  • Create, sort and search 1-D, multi-dimensional and jagged arrays, and know that arrays are reference types
  • Slice arrays and strings with ^ indices and .. ranges, and view part of an array with Span
01

Strings are immutable

A C# string is a sequence of char values, and once created it can never change. Every method that looks like it modifies a string — ToUpper, Replace, Trim — actually returns a new string and leaves the original alone. If you call one and throw away the result, nothing happens. Immutability is what makes strings safe to share between variables, threads and dictionary keys: no one can change the text under you.

C#Program.cs
string name = "  ada lovelace  ";
name.Trim();                        // result thrown away!
Console.WriteLine($"[{name}]");

name = name.Trim();                 // keep the new string
Console.WriteLine($"[{name}]");

string upper = name.ToUpperInvariant();
Console.WriteLine($"{name} / {upper}");

string a = "hello";
string b = a;          // both refer to the same text
a += " world";         // a now refers to a NEW string
Console.WriteLine($"a={a}, b={b}");

Console.WriteLine(name[0]);          // reading a char is fine
Console.WriteLine("abc" == "ab" + "c"); // == compares the text
Outputcompiled & run with real C#
[  ada lovelace  ]
[ada lovelace]
ada lovelace / ADA LOVELACE
a=hello world, b=hello
a
True
Your turn

Capitalise just the first letter of name by combining char.ToUpperInvariant(name[0]) with name[1..].

Error you will hit

CS0200: trying to change a character in a string

C#
string word = "hello";
word[0] = 'H';
Console.WriteLine(word);
Program.cs(2,1): error CS0200: Property or indexer 'string.this[int]' cannot be assigned to -- it is read only
Why the compiler said that

The string indexer only has a getter. Allowing word[0] = 'H' would mutate a string that other variables may share, which immutability forbids.

The fix

Build a new string, or copy to a char[], change it, and make a string from it.

C#
string word = "hello";
word = char.ToUpperInvariant(word[0]) + word[1..];

char[] chars = "hello".ToCharArray();
chars[0] = 'J';
Console.WriteLine(word + " " + new string(chars));
string versus String
string is the C# keyword alias for the .NET type System.String, just as int is System.Int32. They are identical. Convention: use string for variables and string.Join/string.IsNullOrEmpty for the static methods.
02

Common string methods

You will use a couple of dozen string methods every day. Positions are zero-based, and methods that search return -1 when nothing is found. Split breaks a string into a string[]; string.Join glues one back together.

C#Program.cs
string s = "order-1042,shipped,2 items";

Console.WriteLine(s.Length);
Console.WriteLine(s.IndexOf(','));
Console.WriteLine(s.IndexOf("missing"));
Console.WriteLine(s.Contains("shipped"));
Console.WriteLine(s.StartsWith("order-"));
Console.WriteLine(s.Substring(6, 4));      // start 6, length 4
Console.WriteLine(s.Replace("shipped", "delivered"));

string[] parts = s.Split(',');
Console.WriteLine($"{parts.Length} parts, status = {parts[1]}");
Console.WriteLine(string.Join(" | ", parts));

Console.WriteLine("42".PadLeft(5, '0'));
Console.WriteLine("  hi  ".Trim().Length);
Console.WriteLine(string.IsNullOrWhiteSpace("   "));
Console.WriteLine(new string('-', 10));
Outputcompiled & run with real C#
26
10
-1
True
True
1042
order-1042,delivered,2 items
3 parts, status = shipped
order-1042 | shipped | 2 items
00042
2
True
----------
Your turn

Split " a, b ,,c " into clean words using Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) and print how many you get.

MethodReturnsNote
Lengthinta property, no parentheses
IndexOf / LastIndexOfint-1 when not found
Contains / StartsWith / EndsWithboolpass a StringComparison to ignore case
Substring(start, length)stringthrows if the range runs past the end
Replace(old, new)stringreplaces every occurrence
Trim / TrimStart / TrimEndstringremoves whitespace (or given chars)
ToUpperInvariant / ToLowerInvariantstringculture-independent case change
Split(sep)string[]options can drop empties and trim
string.Join(sep, items)stringthe opposite of Split
string.IsNullOrWhiteSpace(s)boolthe right check for "blank" user input
Error you will hit

ArgumentOutOfRangeException: Substring past the end

C#
string code = "AB";
Console.WriteLine(code.Substring(1, 5));
Unhandled exception. System.ArgumentOutOfRangeException: Index and length must refer to a location within the string. (Parameter 'length')
   at System.String.ThrowSubstringArgumentOutOfRange(Int32 startIndex, Int32 length)
   at System.String.Substring(Int32 startIndex, Int32 length)
   at Program.<Main>$(String[] args) in Program.cs:line 2
Why the compiler said that

Substring(1, 5) asks for five characters starting at index 1, but only one exists. Unlike some languages, C# does not quietly return what it can — it throws, and names the bad parameter.

The fix

Clamp the length with Math.Min, or check Length first.

C#
string code = "AB";
int start = 1;
Console.WriteLine(code.Substring(start, Math.Min(5, code.Length - start)));
Error you will hit

NullReferenceException: calling a method on a null string

C#
string? nickname = null;
Console.WriteLine(nickname.Length);
Program.cs(2,19): warning CS8602: Dereference of a possibly null reference.
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.<Main>$(String[] args) in Program.cs:line 2
Why the compiler said that

null means "no string at all", so there is no object whose Length could be read. The ? on string? told the compiler it may be null, and it warned (CS8602) — but a warning does not stop the build.

The fix

Check for null, or use the null-conditional ?. with a ?? fallback.

C#
string? nickname = null;
Console.WriteLine(nickname?.Length ?? 0);
if (!string.IsNullOrEmpty(nickname))
    Console.WriteLine(nickname.ToUpperInvariant());
03

StringBuilder for building text in a loop

Because strings are immutable, s += x in a loop copies the whole string every time: 10,000 appends copy roughly 50 million characters. System.Text.StringBuilder keeps a growable buffer of characters and only produces a string when you call ToString(). Use it whenever you append in a loop; for a handful of pieces, interpolation or + is clearer and just as fast.

C#Program.cs
using System.Text;

var sb = new StringBuilder();
sb.Append("Squares:");
for (int i = 1; i <= 5; i++)
{
    sb.Append(' ').Append(i * i);      // calls chain
}
sb.AppendLine();
sb.AppendLine("done");
sb.Insert(0, "> ");
sb.Replace("done", "DONE");

Console.Write(sb.ToString());
Console.WriteLine($"length {sb.Length}");

var csv = new StringBuilder();
string[] names = { "ana", "ben", "cy" };
for (int i = 0; i < names.Length; i++)
{
    if (i > 0) csv.Append(',');
    csv.Append(names[i]);
}
Console.WriteLine(csv);
Outputcompiled & run with real C#
> Squares: 1 4 9 16 25
DONE
length 28
ana,ben,cy

The line-ending characters that AppendLine adds count toward Length (on Windows they are two characters, \r\n, so the number differs).

Your turn

Rewrite the CSV loop as one line with string.Join. When would StringBuilder still be the better tool?

04

Escape sequences, verbatim and raw string literals

In a normal string, \ starts an escape sequence: \n newline, \t tab, \" quote, \\ a backslash. That makes Windows paths and regular expressions painful. A verbatim string, @"...", turns escapes off (write "" for a quote). A raw string literal (C# 11+) starts and ends with three or more quotes and needs no escaping at all — ideal for JSON, SQL and multi-line text. Its indentation is trimmed to the position of the closing quotes.

C#Program.cs
string escaped = "C:\\logs\\app.txt";
string verbatim = @"C:\logs\app.txt";
Console.WriteLine(escaped == verbatim);
Console.WriteLine(verbatim);
Console.WriteLine(@"She said ""hi"".");

string json = """
    {
      "name": "Ada",
      "tags": ["math", "code"]
    }
    """;
Console.WriteLine(json);

string user = "ada";
int id = 7;
string sql = $"""
    SELECT * FROM users
    WHERE name = '{user}' AND id = {id}
    """;
Console.WriteLine(sql);
Outputcompiled & run with real C#
True
C:\logs\app.txt
She said "hi".
{
  "name": "Ada",
  "tags": ["math", "code"]
}
SELECT * FROM users
WHERE name = 'ada' AND id = 7

Building SQL by pasting values in is shown only to demonstrate interpolation. Real database code passes values as parameters to prevent SQL injection.

Your turn

Write a raw literal containing a literal brace, such as {"id": 1}, with interpolation. (Hint: $$""" makes {{x}} the interpolation hole and a single { plain text.)

05

Comparing strings: ordinal versus culture

== and string.Equals(a, b) compare strings ordinally: character by character, by numeric code, case-sensitive. That is fast and predictable. Some other operations — CompareTo, string.Compare, sorting with the default comparer, ToUpper() — use the current culture, so the same code can give different answers on machines with different regional settings. The fix is to say what you mean with a StringComparison or StringComparer.

C#Program.cs
string a = "Apple", b = "apple";

Console.WriteLine(a == b);
Console.WriteLine(string.Equals(a, b, StringComparison.OrdinalIgnoreCase));
Console.WriteLine("report.PDF".EndsWith(".pdf", StringComparison.OrdinalIgnoreCase));

// ordinal order compares char codes: every uppercase letter sorts before lowercase
Console.WriteLine(string.Compare("Zebra", "apple", StringComparison.Ordinal) < 0);
Console.WriteLine(string.Compare("Zebra", "apple", StringComparison.OrdinalIgnoreCase) < 0);

string[] words = { "banana", "Cherry", "apple" };
Array.Sort(words, StringComparer.Ordinal);
Console.WriteLine(string.Join(", ", words));
Array.Sort(words, StringComparer.OrdinalIgnoreCase);
Console.WriteLine(string.Join(", ", words));

// a dictionary whose keys ignore case
var ages = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["Ada"] = 36 };
Console.WriteLine(ages["ADA"]);
Outputcompiled & run with real C#
False
True
True
True
False
Cherry, apple, banana
apple, banana, Cherry
36
Your turn

Check whether "Hello World" contains "WORLD", ignoring case, using the Contains overload that takes a StringComparison.

C#Program.cs
using System.Globalization;

// The same call, two cultures, two answers.
var turkish = new CultureInfo("tr-TR");
Console.WriteLine("title".ToUpper(turkish));
Console.WriteLine("title".ToUpperInvariant());

bool sameInTurkish = string.Equals("FILE".ToLower(turkish), "file", StringComparison.Ordinal);
Console.WriteLine(sameInTurkish);
Outputcompiled & run with real C#
TİTLE
TITLE
False

In Turkish, lowercase i uppercases to a dotted İ and uppercase I lowercases to a dotless ı. Code that lowercases a file extension with the current culture breaks for every Turkish user.

The rule most teams follow
Use Ordinal or OrdinalIgnoreCase for identifiers, file names, keys, URLs, protocol text and anything a program reads. Use culture-aware comparison (StringComparison.CurrentCulture) only for text a person reads, such as sorting names in a UI. Code analysers (CA1307, CA1309, CA1310) flag calls that leave the choice implicit.
06

Arrays: 1-D, multi-dimensional and jagged

An array is a fixed-size block of elements of one type, indexed from 0. Its length is set when it is created and never changes (use List<T>, from Module 08, when it must grow). A new array is filled with the type's default: 0, false, '\0' or null. Array.Sort, Array.IndexOf and Array.Reverse cover the everyday jobs.

C#Program.cs
int[] empty = new int[3];              // { 0, 0, 0 }
int[] scores = { 72, 95, 60, 88 };
string[] days = new[] { "Mon", "Tue", "Wed" };

Console.WriteLine($"{empty.Length} zeros: {string.Join(",", empty)}");
Console.WriteLine($"first {scores[0]}, last {scores[scores.Length - 1]}");

scores[2] = 65;                       // elements can change, length cannot
Array.Sort(scores);
Console.WriteLine(string.Join(" ", scores));
Console.WriteLine(Array.IndexOf(scores, 88));
Console.WriteLine(Array.IndexOf(scores, 100));
Array.Reverse(days);
Console.WriteLine(string.Join(" ", days));

int total = 0;
foreach (int s in scores) total += s;
Console.WriteLine($"average {(double)total / scores.Length}");
Outputcompiled & run with real C#
3 zeros: 0,0,0
first 72, last 88
65 72 88 95
2
-1
Wed Tue Mon
average 80
Your turn

Find the largest score with a loop, then check your answer with scores.Max().

Arrays are reference types

VisualizeTwo variables, one arrayStep 1 / 6
int[] a = { 1, 2, 3 };
int[] b = a;
b[0] = 99;
int[] c = (int[])a.Clone();
c[1] = 0;
Console.WriteLine($"{a[0]} {a[1]} {c[0]}");
Line 1

One array is created; a holds a reference to it.

Variables now
a→ [1, 2, 3]
All 6 steps as a table
StepLineWhat happenedVariables now
11One array is created; a holds a reference to it.a = → [1, 2, 3]
22Assignment copies the reference, not the elements. b points at the same array.a = → [1, 2, 3] b = → same array
33Writing through b changes the one shared array, so a sees it too.a = → [99, 2, 3] b = → same array
44Clone makes a separate copy of the elements.a = → [99, 2, 3] c = → [99, 2, 3] (copy)
55Changing the copy leaves the original alone.a = → [99, 2, 3] c = → [99, 0, 3]
66Prints a[0], a[1] and c[0].

For tables of data C# has two shapes. A multi-dimensional (rectangular) array, int[,], is one block where every row has the same length; you index it as grid[row, col] and ask each dimension's size with GetLength. A jagged array, int[][], is an array of arrays, so each row can have its own length.

C#Program.cs
int[,] grid =
{
    { 1, 2, 3 },
    { 4, 5, 6 },
};
Console.WriteLine($"{grid.GetLength(0)} rows x {grid.GetLength(1)} cols, Length {grid.Length}");
Console.WriteLine(grid[1, 2]);

int[][] jagged =
{
    new[] { 1 },
    new[] { 2, 3, 4 },
    new[] { 5, 6 },
};
foreach (int[] row in jagged)
    Console.WriteLine($"{row.Length}: {string.Join(" ", row)}");
Console.WriteLine(jagged[1][2]);
Outputcompiled & run with real C#
2 rows x 3 cols, Length 6
6
1: 1
3: 2 3 4
2: 5 6
4
Error you will hit

IndexOutOfRangeException: looping one step too far

C#
int[] scores = { 90, 85, 77 };
for (int i = 0; i <= scores.Length; i++)
    Console.WriteLine(scores[i]);
90
85
77
Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.
   at Program.<Main>$(String[] args) in Program.cs:line 3
Why the compiler said that

Valid indexes run from 0 to Length - 1. With <= the loop makes a fourth pass with i = 3, which does not exist. .NET checks every array access, so this is a clean exception rather than reading random memory as C would.

The fix

Use < in the condition — or use foreach, which cannot go out of bounds.

C#
int[] scores = { 90, 85, 77 };
for (int i = 0; i < scores.Length; i++)
    Console.WriteLine(scores[i]);
07

Indices, ranges and Span<T>

C# 8 added two operators for positions. ^n is an index from the end: ^1 is the last element, ^2 the one before it. a..b is a range from a (included) to b (excluded); either end can be left off. They work on arrays, strings, lists (indices only) and spans. On an array or a string, a range makes a copy of that part.

C#Program.cs
int[] n = { 10, 20, 30, 40, 50 };

Console.WriteLine(n[^1]);                         // last
Console.WriteLine(n[^2]);                         // second to last
Console.WriteLine(string.Join(",", n[1..3]));    // index 1 and 2
Console.WriteLine(string.Join(",", n[..2]));     // first two
Console.WriteLine(string.Join(",", n[^2..]));    // last two

string file = "report.final.pdf";
Console.WriteLine(file[..6]);
Console.WriteLine(file[(file.LastIndexOf('.') + 1)..]);

int[] slice = n[1..3];
slice[0] = 0;                                      // a copy: n is unchanged
Console.WriteLine(n[1]);
Outputcompiled & run with real C#
50
40
20,30
10,20
40,50
report
pdf
20
Your turn

Print every element except the first and the last using a single range.

Span<T> is a view over a stretch of memory, such as part of an array, with no copy. Slicing a span is free, and writes go straight through to the underlying array. ReadOnlySpan<char> does the same for strings, which lets parsing code look at pieces of a string without allocating new ones. Spans live only on the stack, so you cannot store one in a field of a class or use one across an await; for those cases there is Memory<T>.

C#Program.cs
int[] data = { 1, 2, 3, 4, 5, 6 };

Span<int> middle = data.AsSpan(2, 3);   // view of 3, 4, 5
middle[0] = 30;                         // writes into data
middle.Reverse();
Console.WriteLine(string.Join(",", data));

Span<int> tail = data.AsSpan()[^2..];   // ranges work on spans too
tail.Fill(0);
Console.WriteLine(string.Join(",", data));

ReadOnlySpan<char> line = "id=42;name=ada";
int eq = line.IndexOf('=');
int semi = line.IndexOf(';');
int id = int.Parse(line[(eq + 1)..semi]);   // no substring allocated
Console.WriteLine(id + 1);
Outputcompiled & run with real C#
1,2,5,4,30,6
1,2,5,4,0,0
43

Most application code never needs Span. It matters in parsers, serializers and hot paths where avoiding allocations cuts garbage-collection time.

Error you will hit

IndexOutOfRangeException: ^0 is one past the end

C#
int[] a = { 1, 2, 3 };
Console.WriteLine(a[^0]);
Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.
   at Program.<Main>$(String[] args) in Program.cs:line 2
Why the compiler said that

^n means Length - n, so ^0 is a[3], one past the last element. That is why the last element is ^1. ^0 is useful only as the end of a range, as in a[1..^0], where the end is excluded.

The fix

Use ^1 for the last element.

C#
int[] a = { 1, 2, 3 };
Console.WriteLine(a[^1]);
Immutable
Cannot be changed after creation. String methods return new strings instead of modifying the original.
StringBuilder
A mutable character buffer for building text piece by piece without copying on every append.
Verbatim string
@"...": backslashes are literal; "" writes one quote.
Raw string literal
"""...""" (C# 11): no escaping at all, multi-line, indentation trimmed to the closing quotes.
Ordinal comparison
Comparing strings by the numeric value of each char; fast and the same on every machine.
Jagged array
An array of arrays, int[][], where each row can have a different length.
Multi-dimensional array
A rectangular array such as int[,], indexed as grid[row, col].
Index from end
^n, meaning Length - n; ^1 is the last element.
Range
a..b, from index a up to but not including b.
Span<T>
A stack-only view over contiguous memory that can be sliced without copying.
Quick check

What does this print? string s = "abc"; s.ToUpper(); Console.WriteLine(s);

Quick check

For int[] a = { 5, 6, 7, 8 };, what is a[1..^1]?

Frequently asked questions

Why are strings immutable in C#?
So they can be shared safely. Many variables, threads and dictionary keys can refer to the same string without anyone changing it underneath them, and the runtime can reuse identical literals. Methods such as Replace and ToUpper return new strings; use StringBuilder when you need to build text in many steps.
Should I use == or Equals to compare strings in C#?
For strings, == compares the text ordinally and case-sensitively, the same as string.Equals(a, b). When you need case-insensitive or culture-aware comparison, call string.Equals with an explicit StringComparison such as OrdinalIgnoreCase so the result does not depend on the machine.
What is the difference between a jagged array and a multidimensional array in C#?
A multidimensional array, int[,], is one rectangular block where every row has the same length and you index it as grid[r, c]. A jagged array, int[][], is an array of separate arrays, so rows can differ in length and you index it as jagged[r][c].

Finish the C# handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.