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 #

PatternMatches
\dDigit: [0-9]
\DNon-digit
\wWord char: [a-zA-Z0-9_]
\WNon-word char
\sWhitespace: space, tab, newline
\SNon-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 #

PatternMeaning
*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 #

PatternMeaning
^Start of string (or start of line with MULTILINE)
$End of string (or end of line with MULTILINE)
\bWord boundary
(abc)Capture group
(?:abc)Non-capturing group
(?<name>abc)Named capture group
a|ba 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.
  • matches vs containsMatchInmatches checks the whole string, containsMatchIn looks 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 via groups["year"]?.value.
  • Store Regex as constants — regex compilation is expensive. Declare as a val at class or object level so it’s compiled only once.
  • replace with a lambda for transformations — when the replacement needs to be computed from the match value (e.g., formatting numbers), use the replace version 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 solvecontains(), startsWith(), split() are much faster and easier to read for simple cases. Regex is a powerful tool for problems that genuinely need it.

← Previous: Date & Time   Next: Build Tools →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact