1. Why a Relational Database? SQL Refresher
The Problem with In-Memory Storage
In Lectures 2 and 3, we stored data in Java HashMap and ArrayList. This worked for learning, but it has a fatal flaw: all data is lost when the application restarts. Try it — stop your Spring Boot application, start it again, and all the posts and users you created are gone.
A database solves this by storing data persistently on disk. Even if your application crashes, the server reboots, or you deploy a new version, the data survives.
Why Relational Databases?
There are two major categories of databases:
Relational databases (SQL) store data in tables with rows and columns, like a spreadsheet. Tables can be linked together through relationships. Examples: MariaDB, PostgreSQL, MySQL, Oracle, SQL Server.
Non-relational databases (NoSQL) store data in various formats — documents (MongoDB), key-value pairs (Redis), graphs (Neo4j), or columns (Cassandra).
For our blog application, a relational database is the natural choice because:
- Blog data is highly structured — posts have titles, content, authors, dates. Users have names, emails, passwords. These fit neatly into table columns.
- Relationships are everywhere — a user has many posts, a post has many comments, a post belongs to categories. Relational databases are built for this.
- Data integrity matters — you do not want a comment pointing to a post that does not exist. Relational databases enforce these rules with foreign keys.
- SQL is a universal skill — learning it once lets you work with any relational database.
SQL — The Language of Relational Databases
SQL (Structured Query Language) is the standard language for interacting with relational databases. If you have taken a database course in school, you have seen SQL before. If not, do not worry — we will cover everything you need.
SQL has four main categories of commands:
| Category | Purpose | Commands |
|---|---|---|
| DDL (Data Definition Language) | Define database structure | CREATE, ALTER, DROP, TRUNCATE |
| DML (Data Manipulation Language) | Manipulate data | INSERT, SELECT, UPDATE, DELETE |
| DCL (Data Control Language) | Control access | GRANT, REVOKE |
| TCL (Transaction Control Language) | Manage transactions | COMMIT, ROLLBACK, SAVEPOINT |
We will use DDL and DML extensively in this series. Do not memorize the categories — just know that SQL covers both “defining the structure” and “working with the data.”
2. MariaDB Overview — History, Features, MySQL Compatibility
The Story of MariaDB
MariaDB has an interesting origin story. In 1995, Michael “Monty” Widenius co-created MySQL, which became the world’s most popular open-source database. In 2008, Sun Microsystems acquired MySQL. In 2010, Oracle acquired Sun Microsystems — and with it, MySQL.
Many in the open-source community were concerned about Oracle’s stewardship of MySQL. So Monty forked MySQL and created MariaDB in 2009, naming it after his younger daughter Maria (MySQL was named after his older daughter My).
MariaDB is a community-developed fork of MySQL. It was designed to be a drop-in replacement — meaning you can switch from MySQL to MariaDB with minimal or no code changes.
Why MariaDB for This Series?
We chose MariaDB for several practical reasons:
- MySQL compatibility: Almost all MySQL tutorials, tools, and drivers work with MariaDB. If you learn MariaDB, you effectively know MySQL too.
- Truly open source: MariaDB is released under the GPL license with no commercial restrictions from Oracle.
- Performance: MariaDB includes optimizations and storage engines (like Aria and ColumnStore) that are not in MySQL.
- Active development: MariaDB often gets new features before MySQL does — window functions, common table expressions, and JSON functions were available in MariaDB first.
- Industry adoption: MariaDB is used by Wikipedia, Google, Samsung, and many others. It is the default database in most Linux distributions.
MariaDB vs MySQL — Key Differences
For day-to-day development, MariaDB and MySQL are almost identical. Here are the notable differences:
| Feature | MariaDB | MySQL |
|---|---|---|
| License | GPL (fully open source) | GPL + proprietary (Oracle controls development) |
| Default storage engine | InnoDB (same as MySQL) | InnoDB |
| JSON support | Yes (stored as text, optimized) | Yes (native binary format) |
| Thread pool | Built-in (all editions) | Enterprise only |
| Sequences | Yes (CREATE SEQUENCE) | No (use AUTO_INCREMENT only) |
| System versioning | Yes (temporal tables) | No |
| JDBC Driver | org.mariadb.jdbc | com.mysql.cj.jdbc |
The JDBC driver difference matters for Spring Boot configuration. We will use the MariaDB driver (org.mariadb.jdbc:mariadb-java-client) throughout this series.
Compatibility with MySQL Tutorials
Because MariaDB is a MySQL fork, almost everything you find in MySQL tutorials works in MariaDB. The SQL syntax, data types, indexing, and most functions are identical. When you see a MySQL example online, you can use it in MariaDB without changes in most cases.
3. Installing MariaDB (Local & Docker)
You have two options for running MariaDB: install it directly on your machine, or use Docker. We will cover both.
Option A: Docker (Recommended)
Docker is the easiest and cleanest way to run MariaDB. It does not touch your system’s configuration, and you can start/stop/reset the database in seconds.
Step 1: Install Docker
If you do not have Docker installed, download it from docker.com. Docker Desktop works on Windows, Mac, and Linux.
Verify the installation:
docker --version
# Docker version 24.x.x, build xxxxx
Step 2: Run MariaDB in a Container
# Pull the official MariaDB image and start a container
docker run --name blogdb \
-e MARIADB_ROOT_PASSWORD=rootpass \
-e MARIADB_DATABASE=blogdb \
-e MARIADB_USER=bloguser \
-e MARIADB_PASSWORD=blogpass \
-p 3306:3306 \
-d mariadb:11
Let us break down every flag:
--name blogdb— Names the container “blogdb” so you can refer to it easily.-e MARIADB_ROOT_PASSWORD=rootpass— Sets the root (admin) password. In production, use a strong password.-e MARIADB_DATABASE=blogdb— Automatically creates a database called “blogdb” on first startup.-e MARIADB_USER=bloguser— Creates a non-root user called “bloguser.”-e MARIADB_PASSWORD=blogpass— Sets the password for bloguser.-p 3306:3306— Maps port 3306 on your machine to port 3306 in the container. This lets you connect to MariaDB atlocalhost:3306.-d— Runs the container in the background (detached mode).mariadb:11— Uses the MariaDB version 11 image.
Step 3: Verify It Is Running
# Check that the container is running
docker ps
# You should see something like:
# CONTAINER ID IMAGE STATUS PORTS NAMES
# a1b2c3d4e5f6 mariadb:11 Up 2 minutes 0.0.0.0:3306->3306/tcp blogdb
Step 4: Connect to MariaDB
# Open a MariaDB shell inside the container
docker exec -it blogdb mariadb -u bloguser -pblogpass blogdb
You should see the MariaDB prompt:
Welcome to the MariaDB monitor.
MariaDB [blogdb]>
Type exit to leave the shell.
Useful Docker Commands:
# Stop the container
docker stop blogdb
# Start it again (data is preserved)
docker start blogdb
# View container logs (useful for debugging)
docker logs blogdb
# Remove the container completely (WARNING: data is lost!)
docker rm -f blogdb
# Restart from scratch
docker rm -f blogdb
docker run --name blogdb ... (same command as above)
Step 5 (Optional): Use Docker Compose
For convenience, create a docker-compose.yml file in your project root:
# docker-compose.yml
version: '3.8'
services:
mariadb:
image: mariadb:11
container_name: blogdb
environment:
MARIADB_ROOT_PASSWORD: rootpass
MARIADB_DATABASE: blogdb
MARIADB_USER: bloguser
MARIADB_PASSWORD: blogpass
ports:
- "3306:3306"
volumes:
# This line persists data even if the container is removed
- mariadb_data:/var/lib/mysql
volumes:
mariadb_data:
Then start with:
docker compose up -d
The volumes section ensures your data survives even if the container is deleted. This is important for development — you do not want to lose your test data every time you recreate the container.
Option B: Native Installation
If you prefer to install MariaDB directly on your machine:
Windows:
- Download the MSI installer from mariadb.org/download
- Run the installer — set the root password when prompted
- The installer adds MariaDB to your PATH automatically
macOS (Homebrew):
brew install mariadb
brew services start mariadb
# Secure the installation (set root password, remove test database)
mariadb-secure-installation
Linux (Ubuntu/Debian):
sudo apt update
sudo apt install mariadb-server
# Start the service
sudo systemctl start mariadb
sudo systemctl enable mariadb
# Secure the installation
sudo mariadb-secure-installation
After native installation, connect with:
mariadb -u root -p
# Enter your root password when prompted
Which Option to Choose?
Docker is recommended for these reasons:
- Clean isolation — MariaDB runs in a container, not on your system
- Easy reset — delete the container and start fresh in seconds
- Consistent environment — same setup regardless of your operating system
- Closer to production — most production deployments use containers
For the rest of this series, we assume Docker is used. If you installed natively, the SQL commands are identical — only the connection method differs.
4. Basic MariaDB Administration — Users, Permissions, Databases
Connecting as Root
The root user is the superadmin of MariaDB. Use it only for administrative tasks, never for your application.
# Docker:
docker exec -it blogdb mariadb -u root -prootpass
# Native:
mariadb -u root -p
Managing Databases
A MariaDB server can host multiple databases. Each database is an isolated namespace containing its own tables, views, and procedures.
-- Show all databases on this server
SHOW DATABASES;
-- Output:
-- +--------------------+
-- | Database |
-- +--------------------+
-- | blogdb |
-- | information_schema |
-- | mysql |
-- | performance_schema |
-- | sys |
-- +--------------------+
-- The ones besides 'blogdb' are system databases — do not touch them.
-- Create a new database (if you did not use the Docker environment variable)
CREATE DATABASE blogdb
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
Let us explain the character set options:
utf8mb4— Supports all Unicode characters, including emojis. Always use this overutf8(which only supports 3-byte characters and cannot store emojis).utf8mb4_unicode_ci— Case-insensitive collation. “Alice” and “alice” are treated as equal in comparisons and sorting. Thecistands for “case insensitive.”
-- Switch to the blogdb database (all subsequent commands will run in this database)
USE blogdb;
-- Drop (delete) a database — WARNING: this permanently deletes everything inside!
-- DROP DATABASE blogdb;
Managing Users
In production, your application should never connect as root. Create a dedicated user with limited permissions.
-- Create a user (if you did not use Docker environment variables)
CREATE USER 'bloguser'@'%' IDENTIFIED BY 'blogpass';
The 'bloguser'@'%' syntax means:
bloguser— the username%— the host.%means “any host” (the user can connect from anywhere). For stricter security, use'localhost'(only local connections) or a specific IP.
-- Grant permissions on the blogdb database
GRANT ALL PRIVILEGES ON blogdb.* TO 'bloguser'@'%';
-- Apply the changes
FLUSH PRIVILEGES;
The GRANT command gives bloguser full access to all tables (*) in the blogdb database. In production, you would be more restrictive:
-- Production example: only allow SELECT, INSERT, UPDATE, DELETE (no DROP, no ALTER)
GRANT SELECT, INSERT, UPDATE, DELETE ON blogdb.* TO 'bloguser'@'%';
FLUSH PRIVILEGES;
Useful user administration commands:
-- Show all users
SELECT User, Host FROM mysql.user;
-- Show permissions for a specific user
SHOW GRANTS FOR 'bloguser'@'%';
-- Change a user's password
ALTER USER 'bloguser'@'%' IDENTIFIED BY 'newpassword';
-- Delete a user
DROP USER 'bloguser'@'%';
The Principle of Least Privilege
A critical security principle: give each user only the permissions they need, and nothing more.
- The root user should only be used for administration (creating databases, creating users).
- Your application user (
bloguser) should only have the permissions needed to run the app (SELECT, INSERT, UPDATE, DELETE on its own database). - A read-only reporting user should only have SELECT permission.
This limits the damage if an attacker gains access to any single account.
5. SQL Essentials Review (CREATE, INSERT, SELECT, UPDATE, DELETE, JOIN)
Let us refresh your SQL skills with the essential commands. Connect to MariaDB and switch to the blogdb database:
docker exec -it blogdb mariadb -u bloguser -pblogpass blogdb
CREATE TABLE
-- Create a simple table to practice with
CREATE TABLE authors (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
bio TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Each column definition has this structure: column_name DATA_TYPE CONSTRAINTS
BIGINT— A large integer type, perfect for IDs.AUTO_INCREMENT— MariaDB automatically assigns the next number (1, 2, 3…). You do not need to specify the ID when inserting.PRIMARY KEY— Uniquely identifies each row. No duplicates, no NULLs.VARCHAR(100)— A variable-length string, up to 100 characters.NOT NULL— This column must have a value. MariaDB rejects inserts with NULL for this column.UNIQUE— No two rows can have the same value in this column.TEXT— A string with no practical length limit (up to 65,535 characters).TIMESTAMP DEFAULT CURRENT_TIMESTAMP— Automatically sets to the current date/time when a row is inserted.
INSERT
-- Insert a single row
INSERT INTO authors (name, email, bio)
VALUES ('Alice Johnson', 'alice@example.com', 'Software engineer and tech blogger');
-- Insert multiple rows at once
INSERT INTO authors (name, email, bio) VALUES
('Bob Smith', 'bob@example.com', 'Full-stack developer'),
('Carol Davis', 'carol@example.com', 'Database specialist'),
('David Wilson', 'david@example.com', NULL);
Notice that we do not specify id (AUTO_INCREMENT handles it) or created_at (the DEFAULT handles it). David has a NULL bio because we explicitly passed NULL and bio allows it (no NOT NULL constraint).
SELECT
-- Select all columns from all rows
SELECT * FROM authors;
-- Select specific columns
SELECT name, email FROM authors;
-- Filter with WHERE
SELECT * FROM authors WHERE name = 'Alice Johnson';
-- Pattern matching with LIKE
-- % means "any characters" (similar to * in file systems)
SELECT * FROM authors WHERE email LIKE '%example.com';
-- Multiple conditions
SELECT * FROM authors WHERE bio IS NOT NULL AND name LIKE 'A%';
-- Ordering results
SELECT * FROM authors ORDER BY name ASC; -- Alphabetical (A → Z)
SELECT * FROM authors ORDER BY created_at DESC; -- Newest first
-- Limiting results (pagination)
SELECT * FROM authors ORDER BY id LIMIT 10; -- First 10 rows
SELECT * FROM authors ORDER BY id LIMIT 10 OFFSET 20; -- Skip 20, get next 10
-- Counting rows
SELECT COUNT(*) AS total_authors FROM authors;
-- Aggregate functions
SELECT COUNT(*) AS total, MIN(created_at) AS earliest, MAX(created_at) AS latest
FROM authors;
UPDATE
-- Update a specific row
UPDATE authors
SET bio = 'Senior software engineer and tech blogger',
email = 'alice.johnson@example.com'
WHERE id = 1;
-- CRITICAL: Always use WHERE with UPDATE!
-- Without WHERE, ALL rows are updated:
-- UPDATE authors SET bio = 'oops'; ← This changes EVERY author's bio!
The WHERE clause is your safety net. Always double-check your WHERE clause before running UPDATE or DELETE.
DELETE
-- Delete a specific row
DELETE FROM authors WHERE id = 4;
-- CRITICAL: Always use WHERE with DELETE!
-- Without WHERE, ALL rows are deleted:
-- DELETE FROM authors; ← This deletes EVERY author!
-- Delete with a condition
DELETE FROM authors WHERE bio IS NULL;
JOIN — Combining Tables
Joins are what make relational databases powerful. They let you combine data from multiple related tables.
First, let us create a second table:
CREATE TABLE articles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
author_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Foreign key: author_id must reference a valid id in the authors table
FOREIGN KEY (author_id) REFERENCES authors(id)
);
-- Insert some articles
INSERT INTO articles (title, content, author_id) VALUES
('Getting Started with Spring Boot', 'Spring Boot simplifies...', 1),
('Advanced SQL Techniques', 'In this article we explore...', 1),
('Introduction to Docker', 'Docker containers provide...', 2),
('REST API Best Practices', 'When designing REST APIs...', 3);
Now let us join them:
-- INNER JOIN: Get articles with their author information.
-- Only returns rows where there is a match in BOTH tables.
SELECT
a.title AS article_title,
a.created_at AS published_date,
au.name AS author_name,
au.email AS author_email
FROM articles a
INNER JOIN authors au ON a.author_id = au.id;
Output:
+-------------------------------+---------------------+---------------+---------------------------+
| article_title | published_date | author_name | author_email |
+-------------------------------+---------------------+---------------+---------------------------+
| Getting Started with Spring | 2024-01-15 10:30:00 | Alice Johnson | alice.johnson@example.com |
| Advanced SQL Techniques | 2024-01-15 10:31:00 | Alice Johnson | alice.johnson@example.com |
| Introduction to Docker | 2024-01-15 10:32:00 | Bob Smith | bob@example.com |
| REST API Best Practices | 2024-01-15 10:33:00 | Carol Davis | carol@example.com |
+-------------------------------+---------------------+---------------+---------------------------+
Let us explore other join types:
-- LEFT JOIN: Get ALL authors, even those with no articles.
-- Authors without articles will have NULL for article columns.
SELECT au.name, a.title
FROM authors au
LEFT JOIN articles a ON au.id = a.author_id;
-- Output includes authors with no articles:
-- | Alice Johnson | Getting Started with Spring Boot |
-- | Alice Johnson | Advanced SQL Techniques |
-- | Bob Smith | Introduction to Docker |
-- | Carol Davis | REST API Best Practices |
-- | David Wilson | NULL | ← No articles
-- GROUP BY with COUNT: How many articles has each author written?
SELECT au.name, COUNT(a.id) AS article_count
FROM authors au
LEFT JOIN articles a ON au.id = a.author_id
GROUP BY au.id, au.name
ORDER BY article_count DESC;
-- Output:
-- | Alice Johnson | 2 |
-- | Bob Smith | 1 |
-- | Carol Davis | 1 |
-- | David Wilson | 0 |
Summary of Join Types
| Join Type | Returns |
|---|---|
INNER JOIN | Only rows with matches in both tables |
LEFT JOIN | All rows from the left table + matches from the right (NULL if no match) |
RIGHT JOIN | All rows from the right table + matches from the left (NULL if no match) |
CROSS JOIN | Every combination of rows from both tables (Cartesian product — rarely used) |
In practice, INNER JOIN and LEFT JOIN cover 95% of use cases.
6. Data Types in MariaDB
Choosing the right data type for each column is important for storage efficiency, query performance, and data integrity. Here are the types you will use most often.
Integer Types
| Type | Storage | Range |
|---|---|---|
TINYINT | 1 byte | -128 to 127 (or 0 to 255 unsigned) |
SMALLINT | 2 bytes | -32,768 to 32,767 |
INT | 4 bytes | -2.1 billion to 2.1 billion |
BIGINT | 8 bytes | -9.2 quintillion to 9.2 quintillion |
Recommendation: Use BIGINT for primary keys and foreign keys. It is overkill for small tables, but you never have to worry about running out of IDs. The storage difference (8 bytes vs 4 bytes) is negligible.
String Types
| Type | Max Length | Use Case |
|---|---|---|
CHAR(n) | 255 chars | Fixed-length strings (country codes, status codes) |
VARCHAR(n) | 65,535 chars | Variable-length strings (names, emails, titles) |
TEXT | 65,535 chars | Long text (article content, descriptions) |
MEDIUMTEXT | 16 MB | Very long text |
LONGTEXT | 4 GB | Extremely long text (rarely needed) |
Recommendation:
- Use
VARCHAR(n)for most strings. Thenis the maximum length — it does not waste space for shorter values. - Use
TEXTfor long-form content like blog post bodies. - Always specify a reasonable max length for
VARCHAR— it serves as documentation and validation.
-- Examples:
name VARCHAR(100) -- Human names are rarely longer than 100 chars
email VARCHAR(255) -- Email standard allows up to 254 chars
title VARCHAR(500) -- Blog post titles
content TEXT -- Blog post body
status VARCHAR(20) -- DRAFT, PUBLISHED, ARCHIVED
slug VARCHAR(300) -- URL-friendly version of the title
Date and Time Types
| Type | Format | Example | Use Case |
|---|---|---|---|
DATE | YYYY-MM-DD | 2024-01-15 | Birthdays, deadlines |
TIME | HH:MM:SS | 14:30:00 | Time-only values |
DATETIME | YYYY-MM-DD HH:MM:SS | 2024-01-15 14:30:00 | Event timestamps (no timezone) |
TIMESTAMP | YYYY-MM-DD HH:MM:SS | 2024-01-15 14:30:00 | Record timestamps (auto-converts to UTC) |
Recommendation: Use TIMESTAMP for created_at and updated_at columns. It automatically converts to UTC for storage and back to local time for display. Use DATETIME when you need to store a specific date and time regardless of timezone (like an event scheduled for “January 15 at 2pm in New York”).
Boolean Type
MariaDB does not have a true boolean type. BOOLEAN is an alias for TINYINT(1):
-- These are equivalent:
is_active BOOLEAN DEFAULT TRUE
is_active TINYINT(1) DEFAULT 1
-- In SQL, TRUE = 1 and FALSE = 0
SELECT * FROM users WHERE is_active = TRUE;
SELECT * FROM users WHERE is_active = 1; -- Same thing
Decimal Types
| Type | Use Case |
|---|---|
FLOAT | Approximate values (calculations where small errors are acceptable) |
DOUBLE | Approximate values with more precision |
DECIMAL(p, s) | Exact values (money, financial data — always use this for money) |
-- DECIMAL(10, 2) means: up to 10 digits total, with 2 decimal places
-- Maximum value: 99999999.99
price DECIMAL(10, 2) -- $99,999,999.99
-- NEVER use FLOAT or DOUBLE for money — they cause rounding errors!
-- FLOAT: 0.1 + 0.2 might equal 0.30000000000000004
-- DECIMAL: 0.1 + 0.2 = 0.3 (exact)
JSON Type
MariaDB supports JSON storage (stored as optimized text internally):
metadata JSON
-- Insert JSON data
INSERT INTO posts (title, metadata) VALUES (
'My Post',
'{"tags": ["java", "spring"], "readTime": 5}'
);
-- Query JSON fields
SELECT title, JSON_EXTRACT(metadata, '$.tags') AS tags
FROM posts;
Use JSON sparingly. If you find yourself querying JSON fields frequently, those fields probably belong in their own table or column.
7. Indexes and Primary Keys
What is an Index?
An index is a data structure that speeds up data retrieval. Without an index, MariaDB must scan every row in the table to find matching records (full table scan). With an index, it can jump directly to the relevant rows.
The analogy is a book index. Without the index at the back of the book, you would have to read every page to find information about “Spring Boot.” With the index, you look up “Spring Boot,” find the page numbers, and go directly there.
Primary Key
Every table should have a primary key — a column (or combination of columns) that uniquely identifies each row.
CREATE TABLE posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY, -- Primary key with auto-increment
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL
);
The primary key automatically creates a unique index. MariaDB uses the primary key to physically organize the data on disk (in InnoDB, the default storage engine), making lookups by primary key extremely fast.
Unique Index
A unique index ensures no two rows have the same value in the indexed column:
-- No two users can have the same email
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
UNIQUE INDEX idx_users_email (email)
);
-- Alternatively, you can add the UNIQUE constraint inline:
-- email VARCHAR(255) NOT NULL UNIQUE
If you try to insert a duplicate email, MariaDB rejects the insert with an error.
Regular Index (Non-Unique)
A regular index speeds up lookups but allows duplicate values:
-- We often query posts by status, so add an index on status
CREATE INDEX idx_posts_status ON posts(status);
-- We often query posts by author_id, so add an index on author_id
CREATE INDEX idx_posts_author_id ON posts(author_id);
Composite Index
An index on multiple columns:
-- If we often filter by both status AND category, a composite index helps
CREATE INDEX idx_posts_status_category ON posts(status, category);
Column order matters in composite indexes. The index (status, category) can speed up queries that filter by:
WHERE status = 'PUBLISHED'(uses the first column)WHERE status = 'PUBLISHED' AND category = 'TUTORIAL'(uses both columns)
But it does NOT speed up:
WHERE category = 'TUTORIAL'(cannot skip the first column)
Think of it like a phone book sorted by last name, then first name. You can look up “Smith” (last name only) or “Smith, Alice” (both), but you cannot efficiently look up just “Alice” (first name only).
When to Add Indexes
Add indexes on columns that are frequently used in:
WHEREclauses (filtering)JOINconditions (foreign keys)ORDER BYclauses (sorting)GROUP BYclauses (aggregation)
Do NOT add indexes on every column. Indexes speed up reads but slow down writes (every INSERT, UPDATE, DELETE must also update the indexes). For most web applications, the right balance is:
- Always: primary keys, foreign keys, unique constraints
- Usually: columns used in WHERE clauses of frequent queries
- Rarely: columns with very low cardinality (like a boolean
is_activewith only two values)
Viewing Indexes
-- Show all indexes on a table
SHOW INDEX FROM posts;
-- See how MariaDB executes a query (shows if indexes are used)
EXPLAIN SELECT * FROM posts WHERE status = 'PUBLISHED';
The EXPLAIN command is your best friend for query optimization. We will use it extensively in Lecture 16.
8. Introduction to Database Design — Normalization Basics
Why Design Matters
A poorly designed database leads to:
- Data redundancy — the same information stored in multiple places
- Update anomalies — changing data in one place but forgetting another
- Insertion anomalies — unable to add data without unrelated data
- Deletion anomalies — losing data unintentionally when deleting
Good database design prevents these problems through normalization.
What is Normalization?
Normalization is the process of organizing a database to reduce redundancy and improve data integrity. It is defined through a series of “normal forms.” For practical purposes, you need to know the first three.
First Normal Form (1NF) — No Repeating Groups
A table is in 1NF if:
- Each column contains only atomic (indivisible) values
- There are no repeating groups or arrays
-- ❌ VIOLATES 1NF — tags stored as a comma-separated string
CREATE TABLE posts_bad (
id BIGINT PRIMARY KEY,
title VARCHAR(255),
tags VARCHAR(500) -- "java,spring,tutorial" — NOT atomic!
);
-- ✅ FOLLOWS 1NF — tags stored in a separate table, one per row
CREATE TABLE posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255)
);
CREATE TABLE post_tags (
post_id BIGINT,
tag VARCHAR(50),
PRIMARY KEY (post_id, tag),
FOREIGN KEY (post_id) REFERENCES posts(id)
);
-- Each tag is its own row:
-- | post_id | tag |
-- |---------|----------|
-- | 1 | java |
-- | 1 | spring |
-- | 1 | tutorial |
Second Normal Form (2NF) — No Partial Dependencies
A table is in 2NF if:
- It is in 1NF
- Every non-key column depends on the entire primary key (not just part of it)
This only applies to tables with composite primary keys (a primary key made of multiple columns).
-- ❌ VIOLATES 2NF — author_name depends only on author_id, not on the full key
CREATE TABLE articles_bad (
article_id BIGINT,
author_id BIGINT,
title VARCHAR(255),
author_name VARCHAR(100), -- Depends only on author_id, not on (article_id, author_id)
PRIMARY KEY (article_id, author_id)
);
-- ✅ FOLLOWS 2NF — separate the author information into its own table
CREATE TABLE authors (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100)
);
CREATE TABLE articles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255),
author_id BIGINT,
FOREIGN KEY (author_id) REFERENCES authors(id)
);
Third Normal Form (3NF) — No Transitive Dependencies
A table is in 3NF if:
- It is in 2NF
- No non-key column depends on another non-key column
-- ❌ VIOLATES 3NF — city and country have a transitive dependency
-- (country depends on city, which depends on the primary key)
CREATE TABLE users_bad (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
city VARCHAR(100),
country VARCHAR(100) -- country depends on city, not directly on id
);
-- If "Hanoi" is always in "Vietnam", storing both is redundant.
-- Changing Hanoi's country in one row but not another creates inconsistency.
-- ✅ FOLLOWS 3NF — separate location information
CREATE TABLE cities (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
country VARCHAR(100)
);
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
city_id BIGINT,
FOREIGN KEY (city_id) REFERENCES cities(id)
);
Practical Advice on Normalization
In real projects, you typically aim for 3NF as a starting point. However, sometimes you deliberately denormalize (break normalization rules) for performance. For example:
- Storing a
comment_counton thepoststable instead of counting comments every time - Storing the author’s
namealongside theauthor_idto avoid a JOIN for common queries
This is a trade-off: denormalization speeds up reads but requires extra effort to keep the duplicated data consistent. We will discuss this more in Lecture 16 (Performance Optimization).
For our blog application, we will follow 3NF and let JPA handle the relationships cleanly.
9. Using a GUI Client (DBeaver / HeidiSQL)
While the command-line client works fine, a GUI client makes database work much more pleasant — especially for exploring data, writing complex queries, and visualizing table structures.
DBeaver (Recommended — Cross-Platform)
DBeaver is a free, open-source database client that works on Windows, Mac, and Linux. It supports MariaDB, MySQL, PostgreSQL, SQLite, Oracle, and dozens of other databases.
Installation:
- Download from dbeaver.io
- Install and launch DBeaver
Connecting to MariaDB:
- Click the New Database Connection button (plug icon)
- Select MariaDB from the list
- Fill in the connection details:
- Host:
localhost - Port:
3306 - Database:
blogdb - Username:
bloguser - Password:
blogpass
- Host:
- Click Test Connection — if it says “Connected,” click Finish
Key Features:
- SQL Editor — Write and execute SQL queries with syntax highlighting and auto-complete
- Table Viewer — Browse table data in a spreadsheet-like view, edit cells directly
- ER Diagrams — Automatically generate entity-relationship diagrams from your tables
- Data Export — Export query results to CSV, JSON, SQL, Excel, and more
- Multiple Connections — Connect to multiple databases simultaneously
HeidiSQL (Windows Only)
HeidiSQL is a lightweight, Windows-only client that is particularly popular with MariaDB and MySQL users.
Installation:
- Download from heidisql.com
- Install and launch HeidiSQL
Connecting:
- Click New in the session manager
- Set Network type to MariaDB or MySQL (TCP/IP)
- Enter: Hostname
127.0.0.1, Userbloguser, Passwordblogpass, Port3306 - Click Open
IntelliJ IDEA Database Tool (Ultimate Only)
If you have IntelliJ IDEA Ultimate, it includes a built-in database tool:
- Open the Database panel (View → Tool Windows → Database)
- Click + → Data Source → MariaDB
- Enter the connection details and click Test Connection
- Click OK
This is convenient because you can write SQL queries and Java code in the same IDE.
Command-Line Client
For quick operations, the command-line client is always available:
# Docker
docker exec -it blogdb mariadb -u bloguser -pblogpass blogdb
# Native installation
mariadb -u bloguser -p blogdb
Useful commands inside the MariaDB shell:
-- Show all tables in the current database
SHOW TABLES;
-- Show the structure of a table
DESCRIBE posts;
-- or
SHOW CREATE TABLE posts;
-- Show the current database
SELECT DATABASE();
-- Show server version
SELECT VERSION();
-- Clear the screen
\! clear
10. Hands-on: Design and Create Tables for a Blog Application
Now let us put everything together and design the database schema for our blog application. This schema will serve us for the rest of the series.
Step 1: Identify the Entities
Our blog application needs these entities:
- Users — People who can create posts and comments
- Posts — Blog articles written by users
- Comments — Responses to posts
- Categories — Groups for organizing posts (e.g., “Tutorial,” “News,” “Opinion”)
- Tags — Labels for posts (e.g., “java,” “spring-boot,” “database”)
Step 2: Identify the Relationships
- A user can write many posts (One-to-Many)
- A user can write many comments (One-to-Many)
- A post belongs to one category (Many-to-One)
- A post can have many comments (One-to-Many)
- A post can have many tags, and a tag can belong to many posts (Many-to-Many)
Step 3: Create the Schema
Connect to your database and run the following SQL:
-- Switch to the blogdb database
USE blogdb;
-- ============================================
-- Drop existing tables (if recreating from scratch)
-- Order matters: drop tables with foreign keys first
-- ============================================
DROP TABLE IF EXISTS post_tags;
DROP TABLE IF EXISTS comments;
DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS users;
-- ============================================
-- 1. USERS TABLE
-- ============================================
CREATE TABLE users (
-- Primary key: auto-incrementing BIGINT
id BIGINT AUTO_INCREMENT PRIMARY KEY,
-- User's display name, required
username VARCHAR(50) NOT NULL,
-- Login email, required and unique (no two users can share an email)
email VARCHAR(255) NOT NULL,
-- Hashed password (we will NEVER store plain text passwords)
-- BCrypt hashes are 60 characters long
password_hash VARCHAR(255) NOT NULL,
-- User's full name, optional
full_name VARCHAR(100),
-- Short biography, optional
bio TEXT,
-- User role for authorization: ROLE_USER, ROLE_AUTHOR, ROLE_ADMIN
role VARCHAR(20) NOT NULL DEFAULT 'ROLE_USER',
-- Soft delete: instead of removing rows, we mark them as inactive
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Timestamps for auditing
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
-- Unique constraints (these also create indexes automatically)
UNIQUE INDEX idx_users_username (username),
UNIQUE INDEX idx_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Let us explain the new concepts:
ON UPDATE CURRENT_TIMESTAMP— Automatically updates theupdated_atcolumn whenever the row is modified. No manual code needed.DEFAULT 'ROLE_USER'— If no role is specified during insert, the user getsROLE_USER.ENGINE=InnoDB— Specifies the storage engine. InnoDB supports transactions and foreign keys. It is the default, but being explicit is good practice.DEFAULT CHARSET=utf8mb4— Sets the character set for the table, ensuring emoji and international character support.- Soft delete (
is_active) — Instead of permanently deleting users, we setis_active = FALSE. This preserves their posts and comments.
-- ============================================
-- 2. CATEGORIES TABLE
-- ============================================
CREATE TABLE categories (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX idx_categories_name (name),
UNIQUE INDEX idx_categories_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
The slug column stores a URL-friendly version of the name. For example, “Getting Started” becomes “getting-started.” This is used in URLs: /categories/getting-started instead of /categories/1.
-- ============================================
-- 3. TAGS TABLE
-- ============================================
CREATE TABLE tags (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
slug VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX idx_tags_name (name),
UNIQUE INDEX idx_tags_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- 4. POSTS TABLE
-- ============================================
CREATE TABLE posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(500) NOT NULL,
slug VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
excerpt VARCHAR(1000),
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
-- Foreign keys: link posts to users and categories
author_id BIGINT NOT NULL,
category_id BIGINT,
-- SEO fields
meta_title VARCHAR(200),
meta_description VARCHAR(500),
-- Timestamps
published_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
-- Unique constraint on slug (for URL routing)
UNIQUE INDEX idx_posts_slug (slug),
-- Index on status (frequently filtered)
INDEX idx_posts_status (status),
-- Index on author_id (frequently joined and filtered)
INDEX idx_posts_author_id (author_id),
-- Index on category_id (frequently joined and filtered)
INDEX idx_posts_category_id (category_id),
-- Foreign key constraints
-- RESTRICT means: do not allow deleting a user who has posts
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE RESTRICT,
-- SET NULL means: if a category is deleted, set category_id to NULL
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Foreign key actions explained:
ON DELETE RESTRICT— Prevents deleting the parent row if child rows exist. You cannot delete a user who has posts.ON DELETE SET NULL— If the parent row is deleted, set the foreign key to NULL. If a category is deleted, posts in that category become uncategorized.ON DELETE CASCADE— If the parent row is deleted, delete all child rows too. We will use this for comments (if a post is deleted, its comments are deleted too).
-- ============================================
-- 5. COMMENTS TABLE
-- ============================================
CREATE TABLE comments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
content TEXT NOT NULL,
-- Foreign keys
post_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
-- Self-referencing foreign key for nested replies
-- A comment can be a reply to another comment
parent_id BIGINT NULL,
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_comments_post_id (post_id),
INDEX idx_comments_author_id (author_id),
INDEX idx_comments_parent_id (parent_id),
-- CASCADE: if a post is deleted, all its comments are deleted too
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE RESTRICT,
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- 6. POST_TAGS TABLE (Many-to-Many junction table)
-- ============================================
-- This table exists solely to represent the many-to-many relationship
-- between posts and tags. It has no columns of its own except the
-- two foreign keys, which together form the primary key.
CREATE TABLE post_tags (
post_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
-- Composite primary key: the combination of post_id + tag_id must be unique
-- This prevents duplicate tag assignments
PRIMARY KEY (post_id, tag_id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Step 4: Insert Sample Data
-- Insert users
INSERT INTO users (username, email, password_hash, full_name, bio, role) VALUES
('alice', 'alice@example.com', '$2a$10$dummyhash1', 'Alice Johnson', 'Senior developer and blogger', 'ROLE_ADMIN'),
('bob', 'bob@example.com', '$2a$10$dummyhash2', 'Bob Smith', 'Full-stack developer', 'ROLE_AUTHOR'),
('carol', 'carol@example.com', '$2a$10$dummyhash3', 'Carol Davis', 'Tech enthusiast', 'ROLE_USER');
-- Insert categories
INSERT INTO categories (name, slug, description) VALUES
('Tutorial', 'tutorial', 'Step-by-step programming tutorials'),
('News', 'news', 'Latest tech news and announcements'),
('Opinion', 'opinion', 'Personal thoughts and perspectives');
-- Insert tags
INSERT INTO tags (name, slug) VALUES
('Java', 'java'),
('Spring Boot', 'spring-boot'),
('Database', 'database'),
('Docker', 'docker'),
('REST API', 'rest-api');
-- Insert posts
INSERT INTO posts (title, slug, content, excerpt, status, author_id, category_id, published_at) VALUES
('Getting Started with Spring Boot',
'getting-started-with-spring-boot',
'Spring Boot makes it easy to create stand-alone, production-grade Spring based applications...',
'A beginner-friendly guide to Spring Boot',
'PUBLISHED', 1, 1, NOW()),
('Understanding REST APIs',
'understanding-rest-apis',
'REST (Representational State Transfer) is an architectural style for designing networked applications...',
'Learn the fundamentals of RESTful API design',
'PUBLISHED', 1, 1, NOW()),
('Docker for Developers',
'docker-for-developers',
'Docker containers provide a consistent environment for development and deployment...',
'A practical guide to Docker',
'DRAFT', 2, 1, NULL),
('The Future of Java',
'the-future-of-java',
'Java continues to evolve with regular releases every six months...',
'Where is Java heading?',
'PUBLISHED', 2, 2, NOW());
-- Assign tags to posts (many-to-many)
INSERT INTO post_tags (post_id, tag_id) VALUES
(1, 1), -- "Getting Started" tagged with "Java"
(1, 2), -- "Getting Started" tagged with "Spring Boot"
(2, 5), -- "Understanding REST" tagged with "REST API"
(2, 2), -- "Understanding REST" tagged with "Spring Boot"
(3, 4), -- "Docker for Developers" tagged with "Docker"
(4, 1); -- "The Future of Java" tagged with "Java"
-- Insert comments
INSERT INTO comments (content, post_id, author_id, parent_id) VALUES
('Great article! Very helpful for beginners.', 1, 3, NULL),
('Thanks Carol! Glad you found it useful.', 1, 1, 1),
('Could you cover Spring Security next?', 1, 2, NULL),
('Excellent overview of REST principles.', 2, 3, NULL);
Step 5: Verify the Schema
Run these queries to verify everything was created correctly:
-- Show all tables
SHOW TABLES;
-- Verify table structures
DESCRIBE users;
DESCRIBE posts;
DESCRIBE comments;
DESCRIBE post_tags;
-- Test the relationships with joins
-- Get all posts with their author name and category
SELECT p.title, u.full_name AS author, c.name AS category, p.status
FROM posts p
JOIN users u ON p.author_id = u.id
LEFT JOIN categories c ON p.category_id = c.id
ORDER BY p.created_at DESC;
-- Get all tags for each post
SELECT p.title, GROUP_CONCAT(t.name SEPARATOR ', ') AS tags
FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
GROUP BY p.id, p.title;
-- Get comments with their authors and reply structure
SELECT
c.content,
u.username AS commenter,
c.parent_id AS reply_to,
c.created_at
FROM comments c
JOIN users u ON c.author_id = u.id
WHERE c.post_id = 1
ORDER BY c.created_at;
-- Count posts per category
SELECT c.name AS category, COUNT(p.id) AS post_count
FROM categories c
LEFT JOIN posts p ON c.id = p.category_id
GROUP BY c.id, c.name;
-- Count posts per author
SELECT u.full_name, COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON u.id = p.author_id
GROUP BY u.id, u.full_name
ORDER BY post_count DESC;
Step 6: Save the Schema as a SQL File
Create a file called schema.sql in your project’s src/main/resources directory. Copy all the CREATE TABLE and INSERT statements above into this file. This serves as documentation and can be used to recreate the database from scratch.
In Lecture 10, we will replace this manual approach with Flyway database migrations for proper version control of your schema.
Step 7: Exercises
- Add a
views_countcolumn: Add an integer columnviews_count(default 0) to thepoststable. Write an UPDATE query that increments the count when a post is viewed.-- Hint: ALTER TABLE posts ADD COLUMN views_count INT NOT NULL DEFAULT 0; UPDATE posts SET views_count = views_count + 1 WHERE id = 1; - Write a “popular posts” query: Select the top 3 posts ordered by comment count. You will need a JOIN with GROUP BY and ORDER BY.
- Write a “recent activity” query: Select the 10 most recent comments across all posts, including the post title, comment content, and commenter username.
- Test foreign key constraints: Try to delete a user who has posts. What happens? Try to delete a category that has posts. What happens? This demonstrates the difference between
ON DELETE RESTRICTandON DELETE SET NULL. - Add a
bookmarkstable: Design and create a junction table that allows users to bookmark posts (many-to-many between users and posts). Insert a few bookmarks and write a query to get all bookmarked posts for a specific user.
Summary
In this lecture, you have set up everything needed on the database side before connecting it to Spring Boot:
- Why relational databases: Persistent storage with structured data, enforced relationships, and data integrity through constraints and foreign keys.
- MariaDB: A community-driven fork of MySQL that is fully compatible, truly open source, and actively developed. Everything you learn with MariaDB applies to MySQL too.
- Installation: Docker provides the cleanest setup — a single command starts a fully configured MariaDB server. Docker Compose with volumes ensures data persistence.
- Administration: Create dedicated users with limited permissions. Never use root for your application. Follow the principle of least privilege.
- SQL essentials: CREATE TABLE defines structure, INSERT adds data, SELECT reads data (with filtering, sorting, pagination), UPDATE modifies data, DELETE removes data, and JOIN combines tables.
- Data types: Use BIGINT for IDs, VARCHAR for strings, TEXT for long content, TIMESTAMP for dates, DECIMAL for money, BOOLEAN for flags.
- Indexes: Speed up reads at the cost of slower writes. Always index primary keys, foreign keys, and frequently queried columns.
- Normalization: Organize tables to reduce redundancy (1NF: atomic values, 2NF: no partial dependencies, 3NF: no transitive dependencies).
- Blog schema: Six tables (users, categories, tags, posts, comments, post_tags) with proper relationships, constraints, indexes, and sample data.
What is Next
In Lecture 5, we will connect Spring Boot to this MariaDB database using Spring Data JPA and Hibernate. You will learn how to map Java classes to database tables using annotations, and replace our in-memory repositories with real database-backed repositories — without writing a single line of SQL.
Quick Reference
| Concept | Description |
|---|---|
| Relational Database | Stores data in tables with rows and columns, linked by relationships |
| MariaDB | Open-source MySQL fork, fully compatible, community-developed |
| SQL | Structured Query Language — standard language for relational databases |
| DDL | Data Definition Language (CREATE, ALTER, DROP) |
| DML | Data Manipulation Language (INSERT, SELECT, UPDATE, DELETE) |
| PRIMARY KEY | Uniquely identifies each row; auto-indexed |
| FOREIGN KEY | Links a column to a primary key in another table |
| UNIQUE | Constraint ensuring no duplicate values |
| NOT NULL | Constraint requiring a value (no NULLs) |
| AUTO_INCREMENT | Automatically assigns the next integer ID |
| INDEX | Data structure that speeds up data retrieval |
| JOIN | Combines rows from multiple tables based on related columns |
| INNER JOIN | Returns only rows with matches in both tables |
| LEFT JOIN | Returns all rows from the left table + matches from the right |
| Normalization | Organizing tables to minimize redundancy (1NF, 2NF, 3NF) |
| ON DELETE CASCADE | Delete child rows when parent is deleted |
| ON DELETE RESTRICT | Prevent deletion of parent if children exist |
| ON DELETE SET NULL | Set foreign key to NULL when parent is deleted |
utf8mb4 | Character set supporting all Unicode including emojis |
| Docker Compose | Tool for defining and running multi-container Docker apps |
| DBeaver | Free cross-platform database GUI client |
