High-Performance Autocomplete Search with Quarkus, Panache, and PostgreSQL
Power intelligent, real-time search experiences with fast indexing, efficient persistence, and a lean Java backend.
When handling massive datasets like global city names, users expect instant feedback as they type. In this tutorial, you'll build a high-performance autocomplete and search service using Quarkus and PostgreSQL with the pg_trgm
extension for fast trigram matching.
We’ll use Hibernate ORM with Panache to simplify persistence, and expose a REST API that supports both paginated search and autocomplete. A lightweight HTML+JavaScript frontend will demonstrate the functionality.
Prerequisites
Java 17+
Apache Maven 3.8+
Podman (and if you have to, you can use Docker)
Quarkus Dev Services will manage PostgreSQL
A terminal with
curl
or Postman for testingA modern web browser for the UI
Create and Configure Your Quarkus Project
Generate a Quarkus project:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=geonames-search \
-Dextensions="rest-jackson,hibernate-orm-panache,jdbc-postgresql"
cd geonames-search
Update src/main/resources/application.properties
:
quarkus.datasource.db-kind=postgresql
#quarkus.hibernate-orm.schema-management.strategy=none
quarkus.hibernate-orm.log.sql=true
This setup uses Dev Services to launch PostgreSQL automatically during development. We manage table and index generation via the import.sql.
And you can also directly go to my Github repository and start with a working example.
Prepare GeoNames Data and Indexing
Download the GeoNames allCountries.zip (direct download link! 413MB!), from GeoNames and unzip it. The GeoNames geographical database covers all countries and contains over eleven million placenames that are available for download free of charge under a Creative Commons Attribution 4.0 License.
Place allCountries.txt
(1.7GB!) where you can copy it into a container later.
Add below to src/main/resources/import.sql
:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE IF NOT EXISTS geoname (
id INT PRIMARY KEY,
name VARCHAR(200),
asciiname VARCHAR(200),
alternatenames TEXT,
latitude DECIMAL(10, 7),
longitude DECIMAL(10, 7),
feature_class CHAR(1),
feature_code VARCHAR(10),
country_code CHAR(2),
cc2 VARCHAR(200),
admin1_code VARCHAR(20),
admin2_code VARCHAR(80),
admin3_code VARCHAR(20),
admin4_code VARCHAR(20),
population BIGINT,
elevation INT,
dem INT,
timezone VARCHAR(40),
modification_date DATE
);
CREATE INDEX IF NOT EXISTS idx_gin_geoname_asciiname ON geoname USING GIN (asciiname gin_trgm_ops);
Define the Entity
Rename MyEntity to src/main/java/org/acme/GeoName.java
and replace the content with:
package org.acme;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Column;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "geoname")
public class GeoName extends PanacheEntityBase {
@Id
public Integer id;
@Column(length = 200)
public String name;
@Column(length = 200)
public String asciiname;
@Column(columnDefinition = "TEXT")
public String alternatenames;
@Column(precision = 10, scale = 7)
public BigDecimal latitude;
@Column(precision = 10, scale = 7)
public BigDecimal longitude;
@Column(name = "feature_class", length = 1)
public String featureClass;
@Column(name = "feature_code", length = 10)
public String featureCode;
@Column(name = "country_code", length = 2)
public String countryCode;
@Column(length = 200)
public String cc2;
@Column(name = "admin1_code", length = 20)
public String admin1Code;
@Column(name = "admin2_code", length = 80)
public String admin2Code;
@Column(name = "admin3_code", length = 20)
public String admin3Code;
@Column(name = "admin4_code", length = 20)
public String admin4Code;
public Long population;
public Integer elevation;
public Integer dem;
@Column(length = 40)
public String timezone;
@Column(name = "modification_date")
public LocalDate modificationDate;
// Panache provides find(), list(), etc. methods for free!
}
Add Search and Autocomplete Endpoints
Rename GreetingResource to src/main/java/org/acme/GeoNameResource.java
and replace the content with:
package org.acme;
import java.util.List;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
@Path("/geonames")
public class GeoNameResource {
private static final int PAGE_SIZE = 10;
/**
* A full-featured, paginated search endpoint.
* It searches for names starting with the term, case-insensitively.
* Results are sorted by population for relevance.
* Example: /geonames/search?term=berlin&page=0
*/
@GET
@Path("/search")
public List<GeoName> search(
@QueryParam("term") String term,
@QueryParam("page") int page) {
// Use Panache's find() method with sorting and paging.
return GeoName.find(
"asciiname ILIKE ?1", // Case-insensitive "starts with" query
Sort.by("population", Sort.Direction.Descending).and("name"),
term + "%").page(Page.of(page, PAGE_SIZE)).list();
}
/**
* A lightweight, high-performance autocomplete endpoint.
* It returns only the names (strings) for a snappy UI experience.
* Thanks to the GIN index, this remains fast on millions of rows.
* Example: /geonames/autocomplete?term=mun
*/
@GET
@Path("/autocomplete")
public List<String> autocomplete(@QueryParam("term") String term) {
// Create a PanacheQuery
var query = GeoName.find(
"asciiname ILIKE ?1",
Sort.by("population", Sort.Direction.Descending).and("name"),
term + "%");
// Limit to the top 10 results for autocomplete
query.page(Page.of(0, 10));
// Project to a list of strings instead of full objects to reduce payload size.
return query.project(String.class).list();
}
}
Why this is fast:
GIN Index (
USING GIN
): A standard B-Tree index is only efficient forLIKE
queries that are anchored at the beginning (e.g.,term%
). A GIN index with thepg_trgm
operator class breaks down the text into trigrams (sequences of 3 characters). This allows PostgreSQL to very quickly find all entries that contain a given search term, makingILIKE
queries extremely fast.Pagination (
.page()
): By fetching only one page of data at a time, we keep memory usage low and response times fast, regardless of the total number of results.Projection (
.project()
): For the autocomplete endpoint, we don't need the wholeGeoName
object. Projecting the query to just theasciiname
field minimizes the data transferred from the database to the application and from the application to the client.
Build a Simple Frontend
Create src/main/resources/META-INF/resources/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-B">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoNames Search</title>
<style>
<!-- omitted for brevity -->
</style>
</head>
<body>
<h1>GeoNames Search</h1>
<form id="search-form">
<input type="text" id="search-box" placeholder="Search for a location..." autocomplete="off">
<div id="autocomplete-list"></div>
<button type="submit">Search</button>
</form>
<div id="results"></div>
<div class="pagination">
<button id="prev-btn" style="display:none;">Previous</button>
<span id="page-info"></span>
<button id="next-btn" style="display:none;">Next</button>
</div>
<script>
const searchBox = document.getElementById('search-box');
const autocompleteList = document.getElementById('autocomplete-list');
const searchForm = document.getElementById('search-form');
const resultsDiv = document.getElementById('results');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
const pageInfo = document.getElementById('page-info');
let currentPage = 0;
let currentTerm = '';
// Autocomplete functionality
searchBox.addEventListener('keyup', async (e) => {
const term = e.target.value;
if (term.length < 3) {
autocompleteList.innerHTML = '';
return;
}
const response = await fetch(`/geonames/autocomplete?term=${term}`);
const suggestions = await response.json();
autocompleteList.innerHTML = '';
suggestions.forEach(suggestion => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.textContent = suggestion;
item.addEventListener('click', () => {
searchBox.value = suggestion;
autocompleteList.innerHTML = '';
searchForm.requestSubmit();
});
autocompleteList.appendChild(item);
});
});
// Search form submission
searchForm.addEventListener('submit', (e) => {
e.preventDefault();
currentTerm = searchBox.value;
currentPage = 0;
autocompleteList.innerHTML = '';
fetchResults();
});
// Pagination button listeners
prevBtn.addEventListener('click', () => {
if (currentPage > 0) {
currentPage--;
fetchResults();
}
});
nextBtn.addEventListener('click', () => {
currentPage++;
fetchResults();
});
async function fetchResults() {
if (!currentTerm) return;
const response = await fetch(`/geonames/search?term=${currentTerm}&page=${currentPage}`);
const results = await response.json();
renderResults(results);
}
function renderResults(results) {
resultsDiv.innerHTML = '';
if (results.length === 0 && currentPage === 0) {
resultsDiv.innerHTML = '<p>No results found.</p>';
}
results.forEach(result => {
const item = document.createElement('div');
item.className = 'result-item';
item.innerHTML = `<b>${result.name}</b> (${result.country_code})<br>Population: ${result.population.toLocaleString()}`;
resultsDiv.appendChild(item);
});
// Update pagination controls
pageInfo.textContent = `Page ${currentPage + 1}`;
prevBtn.style.display = currentPage > 0 ? 'inline' : 'none';
nextBtn.style.display = results.length === 10 ? 'inline' : 'none'; // Show next if we got a full page
}
</script>
</body>
</html>
Run the Application
Start your Quarkus app:
./mvnw quarkus:dev
Manually import the data using:
# Find your Dev Services PostgreSQL container
podman ps
podman cp allCountries.txt <container_id>:/allCountries.txt
podman exec -it <container_id> psql -U quarkus -d quarkus -c "
COPY geoname(id, name, asciiname, alternatenames, latitude, longitude, feature_class, feature_code, country_code, cc2, admin1_code, admin2_code, admin3_code, admin4_code, population, elevation, dem, timezone, modification_date)
FROM '/allCountries.txt'
WITH (FORMAT text, DELIMITER E'\t', NULL '')
"
After a while you’ll get a response: COPY 13337730
Visit http://localhost:8080 in your browser.
Start typing and autocomplete should kick in after 3 characters.
Submit the form to view full results, sorted by population.
What You’ve Built
You now have:
A fast autocomplete API using PostgreSQL trigram indexes
A full-text search service
A frontend served by Quarkus using plain HTML and JS
From here, consider adding fuzzy matching, bounding box filters, or integrating a full map-based UI. For even more power, try LangChain4j to make your search interface conversational.
Hi,
PostgreSQL will most likely not use a GIN trigram index, because term + '%' is a prefixed search, and it is perfectly optimized by a btree index over a regular collation (if case is not important - via citext).