Regex #
A regular expression (regex) is a mini-language for describing patterns in text. With a single line pattern, you can validate an email format, extract every phone number from a document, or replace all dates in an old format with a new one. Kotlin provides the Regex class, which is immutable and thread-safe, with a cleaner API than Java. One big advantage in Kotlin: raw strings (triple-quote) let you write regex patterns without the painful double-escaping. This article covers all of Kotlin’s regex capabilities — from basic syntax to named capture groups, matching modes, and validation patterns commonly used in real-world applications.
Creating a Regex #
There are two ways to create a Regex object:
// Method 1: Regex constructor
val numberPattern = Regex("\\d+")
// Method 2: toRegex() extension function on String
val numberPattern2 = "\\d+".toRegex()
// Method 3 (recommended): raw string — no need to escape backslashes
val numberPattern3 = """\d+""".toRegex()
Raw Strings for Regex #
In Kotlin, \\d and \d are different things. In a regular string, \d is an invalid escape sequence, so you must write \\d for the \d characters to reach the regex engine. In a raw string (triple-quote), there’s no escaping — \d goes straight to the regex as-is.
// Regular string: needs double escaping — error-prone and hard to read
val normalEmail = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$".toRegex()
// Raw string: write it as-is — far cleaner
val rawEmail = """^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$""".toRegex()
// Raw strings also support multiline for documentation
val phoneNumber = """
^(\+62|0) # prefix: +62 or 0
[0-9]{2,3} # area code
[-\s]? # optional separator
[0-9]{3,4} # middle block
[-\s]? # optional separator
[0-9]{4}$ # final block
""".trimIndent().toRegex(RegexOption.COMMENTS)
Regex Pattern Reference #
Before discussing methods, it’s important to understand the most commonly used pattern elements:
Characters and Classes #
| Pattern | Matches |
|---|---|
\d | Digit: [0-9] |
\D | Non-digit |
\w | Word char: [a-zA-Z0-9_] |
\W | Non-word char |
\s | Whitespace: space, tab, newline |
\S | Non-whitespace |
. | Any character (except newline) |
[abc] | One of a, b, or c |
[^abc] | Not a, b, or c |
[a-z] | Lowercase letters a to z |
[A-Za-z0-9] | Alphanumeric |
Quantifiers #
| Pattern | Meaning |
|---|---|
* | 0 or more times |
+ | 1 or more times |
? | 0 or 1 time (optional) |
{n} | Exactly n times |
{n,} | At least n times |
{n,m} | Between n and m times |
*? | 0 or more, non-greedy |
+? | 1 or more, non-greedy |
Anchors and Groups #
| Pattern | Meaning |
|---|---|
^ | Start of string (or start of line with MULTILINE) |
$ | End of string (or end of line with MULTILINE) |
\b | Word boundary |
(abc) | Capture group |
(?:abc) | Non-capturing group |
(?<name>abc) | Named capture group |
a|b | a or b |
(?=abc) | Positive lookahead |
(?!abc) | Negative lookahead |
Main Methods #
matches — Match the Entire String
#
matches checks whether the entire string fits the pattern (not just a part):
val digits = """\d+""".toRegex()
println(digits.matches("12345")) // true — the whole string is digits
println(digits.matches("123abc")) // false — there are non-digit characters
println(digits.matches("")) // false — must be at least 1 digit
// Compare with containsMatchIn
println(digits.containsMatchIn("there is 123 here")) // true — some part matches
println(digits.matches("there is 123 here")) // false — the whole string doesn't match
// Common usage: format validation
val zipCode = """\d{5}""".toRegex()
println(zipCode.matches("12345")) // true
println(zipCode.matches("1234")) // false — fewer than 5 digits
println(zipCode.matches("123456")) // false — more than 5 digits
find and findAll — Find Matches
#
find looks for the first match, findAll looks for all matches:
val digits = """\d+""".toRegex()
val text = "Order #123 contains 5 items worth Rp450000"
// find — the first match
val first = digits.find(text)
println(first?.value) // 123
println(first?.range) // 10..12
// find with a start position
val fromPosition = digits.find(text, startIndex = 15)
println(fromPosition?.value) // 5
// findAll — all matches
val all = digits.findAll(text)
all.forEach { println(it.value) }
// 123
// 5
// 450000
// Convert to a list
val numberList = digits.findAll(text).map { it.value.toInt() }.toList()
println(numberList) // [123, 5, 450000]
println(numberList.sum()) // 450128
Capture Groups and groupValues
#
Capture groups () allow extracting specific parts of a match:
// Extract date components from the dd/MM/yyyy format
val dateFormat = """(\d{2})/(\d{2})/(\d{4})""".toRegex()
val text = "Birth date: 17/08/1945"
val match = dateFormat.find(text)
if (match != null) {
println(match.value) // 17/08/1945 (full match)
println(match.groupValues[0]) // 17/08/1945 (group 0 = the whole match)
println(match.groupValues[1]) // 17 (group 1 = day)
println(match.groupValues[2]) // 08 (group 2 = month)
println(match.groupValues[3]) // 1945 (group 3 = year)
}
Named Capture Groups — More Expressive #
Named capture groups (?<name>pattern) make code much easier to read:
// Named groups — clearer than numeric indexes
val namedDateFormat = """(?<day>\d{2})/(?<month>\d{2})/(?<year>\d{4})""".toRegex()
val match = namedDateFormat.find("Proclamation: 17/08/1945")
if (match != null) {
val day = match.groups["day"]?.value
val month = match.groups["month"]?.value
val year = match.groups["year"]?.value
println("$day $month $year") // 17 08 1945
}
// Example: parse a log entry
val logFormat = """(?<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[(?<level>\w+)\] (?<message>.+)""".toRegex()
val log = "2024-08-17 10:30:45 [ERROR] Database connection failed"
logFormat.find(log)?.let { m ->
println("Time: ${m.groups["time"]?.value}") // 2024-08-17 10:30:45
println("Level: ${m.groups["level"]?.value}") // ERROR
println("Message: ${m.groups["message"]?.value}") // Database connection failed
}
replace — Replace Matches
#
val extraSpaces = """\s+""".toRegex()
val text = "Kotlin is a great language"
// Replace all extra spaces with a single space
println(extraSpaces.replace(text, " "))
// Kotlin is a great language
// replace with transformation — transform each match with a lambda
val digits = """\d+""".toRegex()
val prices = "Price: 50000 and discount: 10000"
val formatted = digits.replace(prices) { match ->
"Rp${\"%,d\".format(match.value.toInt())}"
}
println(formatted) // Price: Rp50,000 and discount: Rp10,000
// replaceFirst — replace only the first match
val firstResult = digits.replaceFirst(prices, "###")
println(firstResult) // Price: ### and discount: 10000
split — Split a String
#
// Split by one or more spaces/commas/semicolons
val separators = """[\s,;]+""".toRegex()
val input = "kotlin, java; python go"
println(separators.split(input))
// [kotlin, java, python, go]
// Split with a limit
println(separators.split(input, limit = 2))
// [kotlin, java; python go]
RegexOption — Matching Options
#
RegexOption changes the matching behavior:
// IGNORE_CASE — ignore letter case
val pattern = "kotlin".toRegex(RegexOption.IGNORE_CASE)
println(pattern.containsMatchIn("I'm learning KOTLIN")) // true
println(pattern.containsMatchIn("I'm learning Kotlin")) // true
// Multiple options at once
val multiOption = """^\d+$""".toRegex(setOf(
RegexOption.MULTILINE, // ^ and $ apply per line
RegexOption.IGNORE_CASE
))
// MULTILINE — ^ and $ match the start/end of each line
val linePattern = """^\w+""".toRegex(RegexOption.MULTILINE)
val multiLine = """
first line
second line
third line
""".trimIndent()
linePattern.findAll(multiLine).forEach { println(it.value) }
// first
// second
// third
// COMMENTS — allow whitespace and comments in the pattern
val commentPattern = """
\d{4} # year
-
\d{2} # month
-
\d{2} # day
""".trimIndent().toRegex(RegexOption.COMMENTS)
println(commentPattern.matches("2024-08-17")) // true
Input Validation — Real Examples #
This is the most common regex use in applications. Always store the pattern as a constant so it isn’t recompiled on every call.
object ValidationPatterns {
// Standard email — doesn't support quoted strings or IP literals
val EMAIL = """^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$""".toRegex()
// Indonesian phone number: +62xxx or 08xx, with or without separators
val ID_PHONE = """^(\+62|0)[0-9]{2,3}[-\s]?[0-9]{3,4}[-\s]?[0-9]{4}$""".toRegex()
// Simple URL
val URL = """^https?://[^\s/$.?#].[^\s]*$""".toRegex()
// Indonesian postal code (5 digits)
val POSTAL_CODE = """\d{5}""".toRegex()
// Indonesian NIK (16 digits)
val NIK = """\d{16}""".toRegex()
// Username: letters, numbers, underscores, 3-20 characters
val USERNAME = """^[a-zA-Z0-9_]{3,20}$""".toRegex()
// Strong password: min 8 characters, uppercase, lowercase, number, and symbol
val UPPERCASE = """[A-Z]""".toRegex()
val LOWERCASE = """[a-z]""".toRegex()
val DIGIT = """[0-9]""".toRegex()
val SYMBOL = """[!@#${'$'}%^&*()_+\-=\[\]{};':",.<>?/]""".toRegex()
}
fun validateEmail(email: String): Boolean =
ValidationPatterns.EMAIL.matches(email)
fun validatePassword(password: String): List<String> {
val issues = mutableListOf<String>()
if (password.length < 8) issues.add("At least 8 characters")
if (!ValidationPatterns.UPPERCASE.containsMatchIn(password)) issues.add("Needs at least 1 uppercase letter")
if (!ValidationPatterns.LOWERCASE.containsMatchIn(password)) issues.add("Needs at least 1 lowercase letter")
if (!ValidationPatterns.DIGIT.containsMatchIn(password)) issues.add("Needs at least 1 number")
if (!ValidationPatterns.SYMBOL.containsMatchIn(password)) issues.add("Needs at least 1 symbol")
return issues
}
// Testing
listOf(
"[email protected]",
"not-an-email",
"user@",
"[email protected]"
).forEach { email ->
println("$email → ${if (validateEmail(email)) "✓ Valid" else "✗ Invalid"}")
}
// [email protected] → ✓ Valid
// not-an-email → ✗ Invalid
// user@ → ✗ Invalid
// [email protected] → ✓ Valid
val passwordIssues = validatePassword("abc")
if (passwordIssues.isEmpty()) println("Strong password!")
else passwordIssues.forEach { println("• $it") }
// • At least 8 characters
// • Needs at least 1 uppercase letter
// • Needs at least 1 number
// • Needs at least 1 symbol
Extracting Data from Text #
Regex is also very useful for extracting structured data from unstructured text:
// Extract all URLs from HTML
val urlRegex = """https?://[^\s"'<>]+""".toRegex()
val html = """
<a href="https://kotlin.unisbadri.com">Kotlin</a>
<img src="https://example.com/gambar.png">
Visit http://docs.kotlin.org for documentation
""".trimIndent()
val urls = urlRegex.findAll(html).map { it.value }.toList()
urls.forEach { println(it) }
// https://kotlin.unisbadri.com
// https://example.com/gambar.png
// http://docs.kotlin.org
// Extract prices from text
val priceRegex = """Rp\s*[\d.,]+""".toRegex(RegexOption.IGNORE_CASE)
val catalog = "Laptop Rp15.000.000, Mouse rp 250.000, Keyboard Rp500.000"
priceRegex.findAll(catalog).forEach { println(it.value) }
// Rp15.000.000
// rp 250.000
// Rp500.000
// Parse a simple CSV
val csvRow = """"Budi Santoso","25","Jakarta","[email protected]""""
val columnRegex = """"([^"]*)"""".toRegex()
val columns = columnRegex.findAll(csvRow).map { it.groupValues[1] }.toList()
println(columns) // [Budi Santoso, 25, Jakarta, [email protected]]
Text Cleaning and Normalization #
// Remove non-alphanumeric characters for a URL slug
fun makeSlug(title: String): String {
val nonAlpha = """[^a-zA-Z0-9\s]""".toRegex()
val doubleSpaces = """\s+""".toRegex()
return title
.lowercase()
.let { nonAlpha.replace(it, "") }
.let { doubleSpaces.replace(it, "-") }
.trim('-')
}
println(makeSlug("Learning Kotlin — The Complete Guide!"))
// learning-kotlin-the-complete-guide
// Remove HTML tags
fun stripHtml(html: String): String {
val htmlTag = """<[^>]+>""".toRegex()
return htmlTag.replace(html, "")
}
println(stripHtml("<h1>Title</h1><p>This is <b>text</b> with HTML</p>"))
// TitleThis is text with HTML
// Normalize Indonesian phone numbers to a standard format
fun normalizePhone(number: String): String? {
val cleaned = """[\s\-()]""".toRegex().replace(number, "")
return when {
cleaned.matches("""^08\d{8,11}$""".toRegex()) ->
"+62" + cleaned.substring(1)
cleaned.matches("""^\+628\d{8,11}$""".toRegex()) -> cleaned
else -> null
}
}
println(normalizePhone("0812-3456-7890")) // +628****7890
println(normalizePhone("+62 812 3456 7890")) // +628****7890
println(normalizePhone("not a number")) // null
Performance Tips and Best Practices #
Compile the Regex Only Once #
// ANTI-PATTERN: the regex is recompiled on every function call
fun badEmailValidation(email: String): Boolean {
return """^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$""".toRegex().matches(email)
}
// CORRECT: compile once as a constant or property
object Validator {
private val EMAIL = """^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$""".toRegex()
fun validateEmail(email: String) = EMAIL.matches(email)
}
// Or as a top-level val
private val REGEX_EMAIL = """^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$""".toRegex()
fun validateEmail(email: String) = REGEX_EMAIL.matches(email)
When Not to Use Regex #
USE regex if:
✓ Complex patterns that regular String methods can't handle
✓ You need to extract specific parts with capture groups
✓ Non-trivial format validation (email, URL, phone number)
✓ Finding all pattern matches in long text
DON'T use regex if:
✗ Checking whether a string contains a substring → use contains()
✗ Checking prefixes/suffixes → use startsWith()/endsWith()
✗ Replacing a literal string → use the regular replace()
✗ Splitting by a fixed delimiter → use the regular split()
✗ The pattern is very simple → String methods are faster and clearer
Summary #
- Use raw strings for regex patterns —
"""\d+"""is far cleaner than"\\d+". Avoid double-escaping that makes patterns hard to read and prone to errors.matchesvscontainsMatchIn—matcheschecks the whole string,containsMatchInlooks anywhere in the string. Choose according to your validation needs.- Named capture groups for readability —
(?<year>\d{4})is clearer than an unnamed\d{4}. Access viagroups["year"]?.value.- Store Regex as constants — regex compilation is expensive. Declare as a
valat class or object level so it’s compiled only once.replacewith a lambda for transformations — when the replacement needs to be computed from the match value (e.g., formatting numbers), use thereplaceversion that accepts a transformation lambda.RegexOption.COMMENTS— use this option with multiline raw strings to document complex regex patterns right in the code.RegexOption.IGNORE_CASE— for case-insensitive matching without explicit[a-zA-Z]in the pattern.- Don’t use regex for what String methods can solve —
contains(),startsWith(),split()are much faster and easier to read for simple cases. Regex is a powerful tool for problems that genuinely need it.