Sometimes, we might want to develop Spring applications without the overhead of a full database system. After all, PostgreSQL and its ilk require configuration and a running server process. That's an over-engineered solution if we don't expect heavy read/write traffic, or if we're trying to rapidly prototype to refine application requirements before committing to a heavier stack.
In such cases, SQLite is a natural fit. The entire database lives in a single file while still supporting SQL queries. Unlike Postgres or MySQL, there's nothing to download or install — the library is embedded directly in your application. And because the database is just a file, it moves with your code. Clone the repo, and the database comes along. Deploy to a new machine, copy the file. No connection strings pointing at remote servers, no Docker containers to spin up, no environment-specific configuration to manage. For prototyping especially, this is liberating: you can iterate on your schema, throw the file away, and start fresh without touching any infrastructure.
That said, such versatility comes with trade-offs. Let's take a look at what SQLite actually is and how it differs from other relational databases.
SQLite vs the Rest
One of the biggest differences between SQLite and other Relational Database Management Systems is that SQLite is an embedded application. Technically, it's a library implemented in ANSI C whose functionality you embed into your application, rather than downloading and configuring a separate Database Management System. When using other RDBMSs, we typically point Hibernate to a port through which we communicate with a database server. With SQLite, we just point to the file itself.
Since all the data lives in a regular file, we can manipulate it as we would any other file. Duplication, deletion, and other operations are trivially simple. Compare this to the rigmarole of manually removing a Postgres installation. (Fortunately, most enterprise-grade RDBMSs come with clean uninstallers unless you’re working on a server with no GUI – in which case I’m glad I’m not you). An underappreciated upside of SQLite’s versatility is that SQLite databases are very easy to share across devices and contexts. It's just a file, after all.
SQLite is also remarkably resilient. The library is famous for its test suite: 92 million lines of test code against 156,000 lines of source. Beyond that, SQLite is one of the most widely deployed pieces of software on the planet, shipping in billions of mobile devices and embedded systems each year. It is thoroughly documented and can be relied upon to behave sensibly under the most demanding circumstances.
But it can't all be sunshine and roses. Most traditional RDBMSs are built for the enterprise, which means distributed environments from the ground up. They natively support sharding, millions of concurrent reads and writes, and ACID compliance at scale. Their codebases are quite large as a consequence, built from the outset to handle that complexity. SQLite, as mentioned, is embedded.
At small scales, SQLite struggles with multiple concurrent writes. And should a programmer try to use it in a distributed context, they would need to implement bespoke concurrency measures themselves.
This difference in scale means SQLite is rarely used by organisations to run core business operations. Enterprise tools like the Java ecosystem are built with PostgreSQL, MariaDB, and the like in mind. Getting SQLite to play nicely in those environments takes a bit of extra effort. Sometimes, though, that effort is worth it — especially when we want to pair an SQLite database with something as capable as the Spring Framework.
Spring and SQLite? Why?
There are good reasons to combine the two. Sometimes.
Minimal Writes
The most obvious case is when there are few or no concurrent reads or writes. Take this blog, for example. It's a Spring Boot application using Thymeleaf for rendering. Once in a while, I write an entry and add it to blog.db. Thanks to Hibernate, I can manipulate entries as Plain Old Java Objects, which makes interfacing with Thymeleaf Models simple and extensible. It feels idiomatically Java.
Rapid Prototyping
Another strong case is early-stage development, when requirements are still in flux. Standing up a Postgres instance, even locally, adds friction: there's a server to start, credentials to configure, and a schema to migrate every time something changes. With SQLite, you delete the file and start over. The feedback loop is tighter, and nothing about your setup needs to change when you eventually swap in a production database. Hibernate and Spring Data abstract over the differences well enough that the migration, when it comes, is mostly a matter of updating your application.properties.
Hardware Constraints
As we learn in school, one of Java's biggest strengths is "Write Once, Run Everywhere." Everywhere includes platforms with tight hardware constraints — a Raspberry Pi or a minimal cloud instance, for example. The Spring Framework is extensible in that you can add or remove unnecessary dependencies. A plain jar is typically a couple of dozen kilobytes, and a small custom JRE is around 30MB. Base Postgres, however, will take about 100MB. Using Spring in such contexts lets us leverage Hibernate's convenient annotations like @Entity as well as Spring MVC's networking suite.
Testing
When running tests we often use the in-memory H2 database. By necessity, this means that each time we run a test, we must insert data into the database — data that does not persist between test runs. With an SQLite database we can reuse the same data across multiple tests, making our test suites leaner and more efficient.
Consider a scenario where we are unit testing a piece of business logic. Ideally, unit tests exercise a unit in complete isolation from its dependencies. When using a volatile H2 database, however, each test must first populate the database with the data it requires. This either pushes persistence concerns into tests that should be focused solely on business logic, or forces us to repeatedly recreate the same test fixtures across the suite. With an SQLite database, we can define a reusable dataset once and share it across many tests, reducing both setup overhead and duplication. If you’re feeling adventurous, you might even seed the database with data extracted from a live environment.
Offline or Local-Only Applications
Another case is when developing applications that need to persist data locally without assuming network access. Consider field equipment or electronics in low-connectivity environments. The benefits of Hibernate should be apparent by now, as should the disadvantages of a remote database server in such a context. SQLite travels with the software.
Read-Heavy Reference Data
You may have data — configurations or lookup tables, for instance — that changes infrequently but is queried often. SQLite is a natural fit here: it implements most of the SQL standard, query optimisation is a well-studied field, and SQLite handles multiple concurrent reads just fine.
How to Use SQLite and Spring Boot
Well, perhaps I managed to sway you and you're willing to give this unlikely pairing a shot. I'll take you through how to use Spring Boot and SQLite in both a containerized and a non-containerized application.
Dependencies
Head on over to Spring Initializr. Or if you're using IntelliJ, open a new project and select Spring Boot from the left pane. In either case, the dependency we want is Spring Data JPA. All the ORM tools we need come with it.
Next, locate build.gradle, or pom.xml if you're using Maven. Either one should be in the base project folder. Near the bottom you should see the dependencies block. Within it you should see the Spring starters you selected. There are three dependencies we must add here: a JDBC driver for SQLite, Hibernate Core, and Hibernate Community Dialects. The first allows our Java/Kotlin application to interface with SQLite database files; the other two allow Hibernate to manipulate the database file.
Your build file should at least contain something like this:
dependencies {
implementation 'org.xerial:sqlite-jdbc:3.53.1.0'
implementation 'org.hibernate.orm:hibernate-core:7.4.0.Final'
implementation 'org.hibernate.orm:hibernate-community-dialects:7.4.0.Final'
}
We're almost done. Now we need to create the database file. Minimal fuss here — just create a new file with the .db extension. Place it under the resources directory, or anywhere else. If you plan to write to this file at runtime, I strongly recommend placing it outside the src folder. Whatever you decide, copy the absolute path; we'll need it in the next step.
The last thing is to configure application.properties. This is where we encounter a major divergence: the configuration will look different depending on whether the application is containerized or not. I'll show you the configuration for this blog, which runs locally uncontainerized but is deployed as a Docker image to Render.
Uncontainerized
If you placed the database file outside the project source directory, you'll need to append the absolute path to the database URL. Otherwise, a relative path will do.
application-local.properties
spring.application.name=BeanFactory
spring.datasource.url=jdbc:sqlite:shared/src/main/resources/databases/blog.db
spring.datasource.username=
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect
Containerized with Docker
application-prod.properties
spring.application.name=BeanFactory
spring.datasource.url=jdbc:sqlite:/app/blog.db
spring.datasource.username=
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect
You might be wondering where /app/blog.db comes from. That depends on the Dockerfile:
FROM amazoncorretto:25-alpine-jdk
LABEL authors="Ryan"
WORKDIR /app
# Copy the JAR file
COPY blog-app/build/libs/blog-app.jar app.jar
# Copy the database file
COPY shared/src/main/resources/databases/blog.db blog.db
EXPOSE 8080
ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "app.jar"]
As you can see, we declare the working directory within the image as /app, then copy our local file into it. Note that this setup requires a precompiled jar. There's nothing stopping you from compiling within the image, but that's beyond the scope of this article.
I hope that this article is informative in some capacity. Remember that there are no rules, only guidelines, and it can be interesting to see what happens when you skirt them.