18
.
02
.
2024
14
.
12
.
2023
Ruby
Ruby on Rails
Postgresql

ILIKE vs LIKE/LOWER - Postgres Stories

Jarosław Kowalewski
Ruby Developer
Jarek Kowalewski - ILIKE vs LIKE/LOWER - Postgres Stories

In web development, the search feature is commonplace. This can be implemented in numerous ways, ranging from simple where conditions in your database to powerful search engines like Elasticsearch, Solr, or Sphinx. In between, there are database tools - LIKE, ILIKE, regular expression queries (potentially enhanced with Trigram Indexes), or Full Text Search, which in Rails is well supported by the pg_search gem.

In this post, I'll examine the ILIKE query and a workaround for improving its efficiency - the use of the LIKE query with the LOWER function. I'll begin with a brief introduction to both.

ILIKE operator

So, in simple terms, ILIKE allows case-insensitive pattern matching. Similarly to LIKE, it can be used with a leading and/or trailing wildcard to find a match:

  • aBc% - strings that start with 'aBc'
  • %aBc - strings that end with 'aBc'
  • %aBc% - strings that contain 'aBc'

Remember that, not as LIKE, ILIKE is not part of the SQL standard - it is only available in PostgreSQL. In other databases such as MySQL or SQLite, you can replicate this functionality by using the COLLATE keyword and choosing the correct collation.

LOWER function

The LOWER function essentially converts all capital letters in text to lowercase. For the purpose of this article, it allows us to substitute an ILIKE query with the combined use of LIKE and LOWER.

Thus, the following two queries should yield equivalent search results:


User.where('last_name ILIKE ?', "%aBc%")

User.where('lower(last_name) LIKE lower(?)', "%aBc%")

Test cases

Let's determine which option is more efficient. For this, I created a users table with approximately 1.5 million records and a string(varchar) column named last_name. This was populated with random English surnames using the Faker gem. My local PostgreSQL version is 13. I will test it with different types of wildcards. To measure the results, I will use the explain analyze option which lets me track the Postgres planner and execution time.

I've developed some methods for the User model in Ruby to simplify query usage in the Rails console. In the first example, I’ve used a leading wildcard. Please note that I have adjusted phrases with correct % in next examples for trailing and leading/trailing wildcards.It is apparent in each plan presented below.


class User < ApplicationRecord
  def self.ilike_search(exp)
    where('last_name ILIKE ?', exp)
  end

  def self.lower_search(exp)
    where('LOWER(last_name) LIKE LOWER(?)', exp)
  end
end

Leading wildcard

I will choose a random phrase that predicts the start of the last name (e.g., 'and' to find 'Anderson', etc.):

User.ilike_search('and%').explain(:analyze)
User.lower_search('and%').explain(:analyze)

The Postgres planner provides a comprehensive plan. The most relevant aspect here is the execution time, especially since I didn't apply any indexes and the query itself is straightforward and brief. To cut to the chase, LIKE/LOWER is faster than ILIKE. This is evident when comparing execution times: 157.634 ms for LOWER/LIKE and 225.187 ms for ILIKE.

Trailing wildcard

For trailing wildcard searches, I'm looking for last names ending with 'Re'. This should also find names ending in 're' (such as D'Amore, Moore, etc.):

User.ilike_search('%Re').explain(:analyze)
User.lower_search('%Re').explain(:analyze)

For the trailing wildcard, the story is similar. LIKE/LOWER takes 171.703 ms, while ILIKE takes 261.146 ms. Thus, LIKE/LOWER wins again.

Combined wildcard

In this step, I will search for all last names containing the phrase 'Son', which also includes 'son'.

User.ilike_search('%Son%').explain(:analyze)
User.lower_search('%Son%').explain(:analyze)

For this example, the story is similar. LIKE/LOWER proved to be faster, taking 195.931 ms compared to 257.449 ms for ILIKE.

In many cases I found, using LOWER with LIKE is faster than ILIKE. However, I will demonstrate an example where it is actually slower. This is an example for combined wildcards, using the phrase ‘So’.

User.ilike_search('%So%').explain(:analyze)
User.lower_search('%So%').explain(:analyze)

Here's an example where LIKE/LOWER is slower (416.909 ms) than ILIKE (255.328 ms). From my tests, this only occurs with combined wildcards which are resource-intensive and time-consuming. Due to their pattern comparison method for each string chunk, combined wildcards are highly sensitive to data distribution. For instance, if many records match the substring 'So,' the case-insensitive ILIKE might perform better as it doesn't require a LOWER operation on every row.

This might suggest that the longer the search term is, which in general yields fewer results, the faster LOWER becomes. Let’s check it by measuring average execution times for specific wildcards.

Average results

To get comprehensive results I will loop each query 100 times with random strings with length from 1 to 5 characters and extract execution time from its planner results to get final outcome. I will manage it with script to calculate average execution time. Obviously, I will also correctly replace wildcards in execution_benchmark method.


def self.random_string(min_length, max_length)
  raise ArgumentError, "Invalid range: #{min_length} to #{max_length}" if min_length > max_length

  characters = ('a'..'z').to_a + ('A'..'Z').to_a
  length = rand(min_length..max_length)

  Array.new(length) { characters.sample }.join
end

def self.execution_benchmark
  total_execution_time = [0,0]
  num_runs = 100
  num_runs.times do
    string = User.random_string(1, 5)
    ilike_result = User.ilike_search("#{string}%").explain(:analyze)
    lower_result = User.lower_search("#{string}%").explain(:analyze)
    ilike_execution_time = ilike_result.split("\n")[-2].match(/Execution Time: (\d+\.\d+) ms/)[1].to_f
    lower_execution_time = lower_result.split("\n")[-2].match(/Execution Time: (\d+\.\d+) ms/)[1].to_f

    total_execution_time[0] += ilike_execution_time
        total_execution_time[1] += lower_execution_time
  end

  average_ilike_time = total_execution_time[0]/num_runs
    average_lower_time = total_execution_time[1]/num_runs

    puts average_ilike_time
    puts average_lower_time
end

Leading wildcard

User.execution_benchmark for leading wildcard:


User Load (218.5ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE 'RsuXJ%')
User Load (142.9ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('RsuXJ%'))
User Load (217.8ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE 'rGPS%')
User Load (146.6ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('rGPS%'))
User Load (228.7ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE 'EFJN%')
User Load (143.9ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('EFJN%'))

...

Average output for ILIKE: 244.70 ms
Average output for LOWER: 173.55 ms
Percentage difference(approximately): LOWER is ****40.1% faster.

Trailing wildcard

User.execution_benchmark for trailing wildcard:


User Load (416.6ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%GWvK')
User Load (146.0ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%GWvK'))
User Load (219.0ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%vlDk')
User Load (145.3ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%vlDk'))
User Load (342.3ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%l')
User Load (581.5ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%l'))

...

Average output for ILIKE: 246.33 ms
Average output for LOWER: 212.78 ms
Percentage difference(approximately): LOWER is 15.8% faster.

Combined wildcard

In this case, it's beneficial to consider three scenarios involving different pattern lengths. The first scenario will address short patterns, second - medium, while the third one will focus on long patterns. I will replace line string = User.random_string(1, 5) with correct ranges.

User.execution_benchmark for combined wildcard and string lengths ranging from 1 to 2:


User Load (278.4ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%BS%')
User Load (457.3ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%BS%'))
User Load (220.4ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%LX%')
User Load (416.8ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%LX%'))
User Load (1924.9ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%C%')
User Load (2084.6ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%C%'))
User Load (2641.5ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%i%')
User Load (3047.9ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%i%'))
...

Average output for ILIKE: 542.89 ms
Average output for LOWER: 559.93 ms
Percentage difference(approximately): LOWER is 3% slower.

User.execution_benchmark for combined wildcard and string lengths ranging from 2 to 5:


User Load (221.3ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%xP%')
User Load (422.2ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%xP%'))
User Load (223.3ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%zXwt%')
User Load (155.8ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%zXwt%'))
User Load (217.3ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%QC%')
User Load (411.0ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%QC%'))
User Load (220.5ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%PpFt%')
User Load (147.1ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%PpFt%'))

Average output for ILIKE: 237.03 ms
Average output for LOWER: 228.27 ms
Percentage difference(approximately): LOWER is 3.8% faster.

User.execution_benchmark for combined wildcard and string lengths ranging from 3 to 7:


User Load (235.6ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%LcdFatK%')
User Load (162.5ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%LcdFatK%'))
User Load (246.4ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%zCSXH%')
User Load (153.7ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%zCSXH%'))
User Load (238.3ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%Luf%')
User Load (151.4ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%Luf%'))
User Load (233.9ms)  SELECT "users".* FROM "users" WHERE (last_name ILIKE '%wUPxBw%')
User Load (150.7ms)  SELECT "users".* FROM "users" WHERE (LOWER(last_name) LIKE LOWER('%wUPxBw%'))

Average output for ILIKE: 230.97 ms
Average output for LOWER: 163.00 ms
Percentage difference(approximately): LOWER is 41% faster.

Conclusion

In conclusion, the LIKE/LOWER combination is usually faster than using ILIKE in PostgreSQL, especially for leading and trailing wildcard searches. However, with combined wildcards, the efficiency can vary based on the length of the search term and the distribution of the data. Therefore, it's crucial to test different scenarios in your production environment to determine the most efficient option. To measure directly on the database server, you might consider using such tool like pganalyze. Alternatively, popular web performance monitoring applications such as Sentry, Rollbar, or Airbrake are also good options. Each one provides tools to measure the speed of query execution in your application and/or tracking for most time consuming queries, enabling you to choose the best fitting option.

Jarosław Kowalewski
Ruby Developer

Check my Twitter

Check my Linkedin

Did you like it? 

Sign up To VIsuality newsletter

READ ALSO

Value Object - DDD in Ruby on Rails

17
.
03
.
2024
Paweł Strzałkowski
Ruby on Rails
Domain-Driven Design
Backend
Tutorial

Introduction to DDD in Ruby on Rails

17
.
03
.
2024
Paweł Strzałkowski
Ruby on Rails
Domain-Driven Design
Backend
Tutorial

Safe data migrations in Rails

17
.
03
.
2024
Paweł Strzałkowski
Ruby on Rails
Backend
Tutorial

I love dev, and so do we!

14
.
11
.
2023
Michał Łęcicki
Software
Conferences

Updated guide to recruitment process at Visuality

14
.
11
.
2023
Michał Łęcicki
Visuality
HR

Visuality Academy for wannabe Junior Engineers

14
.
11
.
2023
Michał Piórkowski
HR
Visuality

How to approach funding as an MVP

14
.
11
.
2023
Michał Piórkowski
Business
Startups

Visuality 13th birthday

14
.
11
.
2023
Michał Piórkowski
HR
Visuality

How To Receive Emails With a Rails App in 2021

14
.
11
.
2023
Michał Łęcicki
Ruby on Rails
Tutorial

Project Quality in IT - How to Make Sure You Will Get What You Want?

14
.
11
.
2023
Wiktor De Witte
Ruby on Rails
Project Management
Business

5 Trends in HR Tech For 2021

14
.
11
.
2023
Maciej Zdunek
Business
Project Management

Is Go Language the Right Choice for Your Next Project?

14
.
11
.
2023
Maciej Zdunek
Backend
Business

SXSW Tradeshow 2020: Get Your FREE Tickets and Meet Us

14
.
11
.
2023
Michał Krochecki
Ruby on Rails
Conferences
Frontend
Backend
Business

How to build effective website: simplicity & McDonald's

14
.
11
.
2023
Lukasz Jackiewicz
Ruby on Rails
Frontend
Design

Thermal Printer Protocols for Image and Text

14
.
11
.
2023
Burak Aybar
Backend
Tutorial
Software

WebUSB - Print Image and Text in Thermal Printers

14
.
11
.
2023
Burak Aybar
Backend
Tutorial
Software

What happened in Visuality in 2019

14
.
11
.
2023
Maciej Zdunek
Visuality
HR

Three strategies that work in board games and in real life

14
.
11
.
2023
Michał Łęcicki
Ruby on Rails