← Back to blog
Dart Fundamentals · Part 8 of 10
July 6, 20268 min read

String Manipulation in Dart: Searching, Splitting, Replacing & RegExp

DartFlutter

String Manipulation in Dart

This is Part 8 of the Dart Fundamentals series. We already covered the basics of strings — literals, quotes, $interpolation, multi-line and raw strings — back in Part 3. So this post deliberately skips all that and dives into the part you actually spend your time on: transforming strings. Searching, slicing, splitting, replacing, cleaning, and pattern-matching.

One thing to keep in the back of your mind throughout: strings are immutable. Every method here returns a brand new string and leaves the original untouched.

var s = 'hello';
s.toUpperCase(); // returns 'HELLO'...
print(s);        // ...but s is still 'hello'
s = s.toUpperCase(); // you must reassign to "change" it

Inspecting a string

The first questions you ask about a string — how long, is it empty, what's at index N:

var s = 'Dart';

s.length;      // 4
s.isEmpty;     // false
s.isNotEmpty;  // true
s[0];          // 'D'  (index access, like a list)
s.codeUnitAt(0); // 68 (the UTF-16 code unit)

Reminder from Part 3: .length and [index] count UTF-16 code units, not human-perceived characters. For emoji and accented text, use the characters package — more on that at the end.


Searching: contains, indexOf, startsWith, endsWith

var path = 'images/photo.png';

path.contains('photo');   // true
path.startsWith('images'); // true
path.endsWith('.png');     // true
path.indexOf('.');         // 12  (first match, or -1 if absent)
path.lastIndexOf('.');     // 12  (last match)

contains is your everyday "is X in here?" indexOf returns the position (or -1 when not found) — handy when you need where, not just whether.


Slicing with substring

Extract a portion by index. The start is inclusive, the end is exclusive (just like list ranges):

var s = 'Hello, World';

s.substring(7);       // 'World'      — from index 7 to the end
s.substring(0, 5);    // 'Hello'      — index 0 up to (not incl.) 5
s.substring(7, 12);   // 'World'

Combine it with indexOf to slice around a delimiter — e.g. grab a file extension:

var file = 'report.final.pdf';
var ext = file.substring(file.lastIndexOf('.') + 1); // 'pdf'

Changing case and trimming whitespace

'hello'.toUpperCase(); // 'HELLO'
'HELLO'.toLowerCase(); // 'hello'

'  spaced  '.trim();      // 'spaced'   — both ends
'  spaced  '.trimLeft();  // 'spaced  ' — left only
'  spaced  '.trimRight(); // '  spaced' — right only

trim() is the unsung hero of form handling — always trim user input before validating it, or a stray space will ruin your day.


Padding and repeating

Useful for aligning output, formatting numbers, building simple tables:

'7'.padLeft(3, '0');    // '007'   — pad to width 3 with '0'
'7'.padRight(3, '.');   // '7..'
'ab' * 3;               // 'ababab' — repeat with the * operator

padLeft(width, char) pads the start until the string reaches width; padRight pads the end. The fill char defaults to a space.


Splitting and joining

split turns a string into a List<String> on a delimiter; join does the reverse on any iterable:

var csv = 'apple,banana,cherry';
var fruits = csv.split(','); // ['apple', 'banana', 'cherry']

fruits.join(' | ');          // 'apple | banana | cherry'

Split on an empty string to get individual characters (UTF-16-wise), and recombine after transforming:

'dart'.split('').reversed.join(); // 'trad'

This split → transform → join pipeline is one of the most common string idioms there is.


Replacing

var s = 'I like cats. Cats are great.';

s.replaceAll('cats', 'dogs');  // replaces every (case-sensitive) match
s.replaceFirst('Cats', 'Dogs'); // only the first match

replaceAll swaps every occurrence; replaceFirst only the first. Both are case-sensitive — which is exactly where regular expressions come in.


Regular expressions with RegExp

When a plain String match isn't expressive enough — "any digit," "case-insensitive," "a word boundary" — you reach for RegExp. Every search/replace method that accepts a String pattern also accepts a RegExp:

var text = 'Order 123, item 456, qty 7';

var digits = RegExp(r'\d+'); // one or more digits; r'' = raw string

text.contains(digits);              // true
text.replaceAll(digits, '#');       // 'Order #, item #, qty #'

// pull out every match
for (final m in digits.allMatches(text)) {
  print(m.group(0)); // 123, then 456, then 7
}

Note the r'\d+' — a raw string (from Part 3) so the backslash reaches the regex engine instead of being interpreted by Dart first. Always write regex patterns as raw strings.

A case-insensitive example:

var ci = RegExp('cats', caseSensitive: false);
'Cats and cats'.replaceAll(ci, 'dogs'); // 'dogs and dogs'

And capturing groups, to extract structured pieces:

var datePattern = RegExp(r'(\d{4})-(\d{2})-(\d{2})');
var match = datePattern.firstMatch('Today is 2026-07-06.');

match?.group(1); // '2026'  (year)
match?.group(2); // '07'    (month)
match?.group(3); // '06'    (day)

(firstMatch returns a nullable RegExpMatch? — null when there's no match — so we use ?. from Part 5.)


StringBuffer — building big strings efficiently

Because strings are immutable, concatenating in a loop creates a new string every iteration — fine for a few joins, wasteful for thousands:

// ❌ allocates a new string on every iteration
var result = '';
for (var i = 0; i < 10000; i++) {
  result += 'line $i\n';
}

StringBuffer accumulates into a mutable buffer and produces the final string just once, at the end:

// ✅ one allocation at the end
var buffer = StringBuffer();
for (var i = 0; i < 10000; i++) {
  buffer.write('line $i');
  buffer.writeln();          // write + newline
}
var result = buffer.toString();

Useful members: write(obj), writeln([obj]), writeAll(iterable, [separator]), .length, and .clear(). Rule of thumb: building a string in a loop? Use a StringBuffer.


A word on Unicode-correct manipulation

Everything above works on UTF-16 code units. For plain ASCII that's perfectly fine. But the moment user text contains emoji, flags, or combined accents, code-unit operations can split a character in half (we saw this in Part 3). For user-facing reversing, truncating, or counting, use the characters package, which works in grapheme clusters:

import 'package:characters/characters.dart';

var s = 'café 👍🏽';

s.length;                    // counts code units — misleading
s.characters.length;        // counts real characters — what humans see
s.characters.takeLast(1);   // the whole 👍🏽, never a broken half

Default to ordinary String methods for internal/ASCII work; switch to .characters whenever real human text is involved.


Practice Challenges

Try first, then check.

Challenge 1 — Initials. Given var name = 'ada lovelace';, produce 'A.L.'.

Show solution
var name = 'ada lovelace';
var initials = name
    .split(' ')
    .map((word) => word[0].toUpperCase())
    .join('.');
initials = '$initials.'; // trailing dot
// → 'A.L.'

Split into words, take the first letter of each, uppercase it, join with dots. A clean split→map→join.

Challenge 2 — Clean a username. Trim whitespace and lowercase ' HelloWorld '.

Show solution
'  HelloWorld  '.trim().toLowerCase(); // 'helloworld'

Method chaining: trim() then toLowerCase(). Always trim user input first.

Challenge 3 — Zero-pad an ID. Turn the int 42 into the string '0042' (width 4).

Show solution
42.toString().padLeft(4, '0'); // '0042'

Convert to a string, then padLeft(4, '0'). (Recall toString() from Part 3.)

Challenge 4 — Count the vowels. Count vowels in 'Dartlang' (case-insensitive).

Show solution
var word = 'Dartlang';
var count = RegExp(r'[aeiou]', caseSensitive: false)
    .allMatches(word)
    .length;
// → 2  (a, a)

A character-class regex plus .allMatches(...).length. You could also filter with where, but regex is tidy here.

Challenge 5 — Mask an email. Turn 'asha@example.com' into 'a***@example.com' (keep first char of the local part, mask the rest before @).

Show solution
var email = 'asha@example.com';
var at = email.indexOf('@');
var local = email.substring(0, at);
var masked = local[0] + '*' * (local.length - 1);
var result = masked + email.substring(at); // 'a***@example.com'

indexOf('@') finds the split point; substring slices the local part and the domain; '*' * n builds the mask.

Challenge 6 — Efficient builder. Build the string "1, 2, 3, ..., 100" using a StringBuffer.

Show solution
var buffer = StringBuffer();
for (var i = 1; i <= 100; i++) {
  buffer.write(i);
  if (i < 100) buffer.write(', ');
}
print(buffer.toString()); // 1, 2, 3, ..., 100

StringBuffer avoids re-allocating the growing string on each iteration. (You could also do [for (var i = 1; i <= 100; i++) i].join(', ') using collection-for from Part 6 — both are idiomatic.)


Check Yourself (Q&A)

Q1. Are Dart strings mutable? No. Every method returns a new string; the original is unchanged. Reassign the variable to "modify" it.

Q2. replaceAll vs replaceFirst? replaceAll swaps every occurrence; replaceFirst only the first. Both are case-sensitive unless you pass a case-insensitive RegExp.

Q3. Why write regex patterns as raw strings (r'...')? So Dart doesn't interpret backslash escapes before the regex engine sees them. r'\d+' passes \d+ through intact; '\d+' would mangle it.

Q4. When should I use StringBuffer? When building a string across many iterations. Repeated += on a String allocates a new string each time; StringBuffer accumulates and produces the result once.

Q5. What does firstMatch return when nothing matches? null (its type is RegExpMatch?), so guard it with ?. or a null check before reading groups.

Q6. When do I need the characters package? Whenever you manipulate user-facing text with emoji/flags/accents — counting, reversing, truncating. Plain String methods count UTF-16 units and can split such characters in half.


Wrapping Up

  • Strings are immutable — methods return new strings.
  • Search with contains/indexOf/startsWith/endsWith; slice with substring.
  • Clean with trim/toLowerCase; align with padLeft/padRight; transform with splitmapjoin.
  • Replace with replaceAll/replaceFirst, escalating to RegExp (always raw strings) for patterns, case-insensitivity, and capture groups.
  • Use StringBuffer for heavy/loop string building, and the characters package for Unicode-correct user text.

Next, in Part 9, we wrap up the language core with control flowif, switch, loops, and Dart 3's powerful pattern matching and switch expressions.