Learn Java

Lesson 81Elective: Desktop apps with JavaFX

Database support of a JavaFX application

Two complete programs, a student CRUD window and an employee manager, then the fixes that make them safe to use and keep the window from freezing.

By Updated 65 min

  • CRUD
  • DBConnection
  • module path and class path
  • PreparedStatement
  • validation
  • row counts

A window forgets everything the moment it closes, and a database has no buttons. This lesson joins them: a button's handler opens a JDBC connection, runs one SQL statement, and writes the answer back into the window as text. You will see it done twice, first with a students table and five buttons, then with an employees table driven by three input fields. Both programs are here in full, run click by click, and then repaired where they fall short.

By the end of this lesson
  • Trace a button click from the JavaFX window down to the MySQL server and back, and say what each layer knows.
  • Compile and run a program that needs both JavaFX and the MySQL driver, and explain why one goes on the module path and the other on the class path.
  • Read StudentApp and EmployeeManagerApp and predict the TextArea text after any sequence of clicks.
  • Find what their first versions get wrong, from a null connection to an uncaught NumberFormatException, and fix each one.
  • Move a database call onto a Task so that a slow database cannot freeze the window.
  • Write a complete JavaFX and JDBC program from a blank page.
From before
Two halves meet here. From the window side: the controls and the Alert dialog from form creation, and setOnAction with a lambda from event handling. From the database side: DriverManager, the connection URL and the studentdb database from connecting with JDBC, and executeUpdate, executeQuery, PreparedStatement and ResultSet from creating and executing queries. The section on freezing borrows Thread and Runnable from threads and the danger of unsynchronized shared data from synchronization.

How the pieces fit#

Both programs in this lesson are built from the same five layers. Each layer knows very little, and only one of them, the event handler, knows about the window and the database at once.

Window: Stage, Scene, VBox, Button, TextField, TextArea
Draws itself and turns each click into an ActionEvent. Knows the text in every field, and nothing about where that text goes.
Event handlers: setOnAction(e -> { ... })
The only code that knows both sides. Reads the fields, sends one SQL statement through JDBC, and writes the result or the error message into the TextArea.
DBConnection.getConnection()
Knows where the database is: the URL jdbc:mysql://localhost:3306/studentdb, the user and the password. Knows no table and no window.
JDBC API (java.sql) and the mysql-connector-j driver
Connection, Statement, PreparedStatement and ResultSet are interfaces in java.sql. The driver jar supplies the classes behind them and speaks MySQL's network protocol.
MySQL server on port 3306
Holds the studentdb database and its tables, runs the SQL it receives, and sends back rows or a count. Knows nothing about Java.
A click travels down the stack. The answer travels back up and ends as text in the TextArea.

Follow one click on “Select Records” down the stack and back up:

  1. 1The JavaFX Application Thread notices the click and calls the lambda that was registered with selectBtn.setOnAction.
  2. 2The handler calls DBConnection.getConnection(). DriverManager passes the URL to the MySQL driver, which opens a network connection to port 3306 on this machine and logs in as root.
  3. 3conn.createStatement() makes a Statement, and executeQuery("SELECT * FROM students") sends the SQL text to the server.
  4. 4MySQL runs the query and sends the rows back. The driver hands them to the handler as a ResultSet.
  5. 5The while (rs.next()) loop copies each row into a StringBuilder, and output.setText(...) puts the finished text in the TextArea.
  6. 6The try-with-resources closes the ResultSet, the Statement and the Connection, in that order. The handler returns, and the FX thread redraws the window with the new text.

Because the layers meet only inside the handlers, you can replace the TextArea with a table control without touching DBConnection, or point DBConnection at another server without touching the window.

Key idea

The event handler is the join. It is the only code that reads controls and calls JDBC: it takes the input from the window, sends one statement to the database, and puts the answer, or the error message, back in the window.

Before you run anything#

A program that needs JavaFX and MySQL at once has more ways to fail on its first run than either half alone. Check these five things first. Nearly every first-run failure is one of them.

  • MySQL is installed and running. The server listens on port 3306. If mysql -u root -p lets you log in at a terminal, or MySQL Workbench connects, the server is up.
  • The database exists. At the mysql> prompt, run CREATE DATABASE studentdb;. StudentApp can create its own table, but neither program can create the database: the URL in DBConnection names studentdb, and connecting to a database that does not exist fails with Unknown database 'studentdb'.
  • The driver jar is in your project folder. MySQL's JDBC driver is Connector/J, today a file called mysql-connector-j-9.7.0.jar (the “Platform Independent” download from dev.mysql.com, or Maven com.mysql:mysql-connector-j). Older guides name mysql-connector-java-8.0.x.jar, the old name of the same driver. Any 9.x jar works with this code.
  • The JavaFX SDK is unzipped somewhere you know. The class uses javafx-sdk-27; run it on JDK 25 or newer. You need the path to its lib folder.
  • The password in DBConnection is yours. Replace your_password with your MySQL root password, or every click ends in Access denied.

Compiling and running#

Put DBConnection.java, StudentApp.java (later also EmployeeManagerApp.java) and the driver jar in one folder, open a terminal there, and compile every .java file at once. The commands are the JavaFX commands from setting up JavaFX with the driver jar added to the class path.

Windows: Command Prompt or PowerShell
read along
1javac --module-path "C:\Program Files\javafx-sdk-27\lib" --add-modules javafx.controls -cp mysql-connector-j-9.7.0.jar -d out *.java
2
3java --enable-native-access=javafx.graphics --module-path "C:\Program Files\javafx-sdk-27\lib" --add-modules javafx.controls -cp "out;mysql-connector-j-9.7.0.jar" StudentApp
Output
javac prints nothing when it succeeds, and the class files land in the out folder.
java opens the StudentApp window. The console prints "Database Connected Successfully!"
each time a button opens a connection.
macOS or Linux: Terminal
read along
1javac --module-path /path/to/javafx-sdk-27/lib --add-modules javafx.controls -cp mysql-connector-j-9.7.0.jar -d out *.java
2
3java --enable-native-access=javafx.graphics --module-path /path/to/javafx-sdk-27/lib --add-modules javafx.controls -cp "out:mysql-connector-j-9.7.0.jar" StudentApp
Output
Same result. Replace /path/to with wherever you unzipped the SDK.
Part of the commandWhy it is there
--module-path ...\libWhere the JavaFX modules are. Both javac and java need it.
--add-modules javafx.controlsLoads javafx.controls, and with it javafx.graphics and javafx.base. You name it yourself because your classes sit on the class path, and code on the class path has no module-info to say requires.
-d out and *.javaCompile DBConnection.java together with the application and put the class files in a folder called out.
-cp on the javac lineOptional. Your code names only interfaces from java.sql, never a MySQL class, so the compiler does not need the driver. It is there so the two commands look alike.
--enable-native-access=javafx.graphicsJDK 24 and newer print “restricted method” warnings when a library loads native code without permission. JavaFX loads its graphics code that way, and this flag grants it.
-cp "out;mysql-connector-j-9.7.0.jar"The class path at run time: your compiled classes in out, plus the driver jar. Windows separates the entries with ;, macOS and Linux with :. Keep the quotes: in PowerShell a bare ; ends the command.
StudentAppThe class whose main runs. Write EmployeeManagerApp here to start the second program.

Why JavaFX and the driver go on different paths#

JavaFX is published as a set of Java modules (javafx.base, javafx.graphics, javafx.controls and others). When the class you launch extends Application, the java launcher checks that the JavaFX modules were loaded as modules. Put the JavaFX jars on the class path instead and it stops with Error: JavaFX runtime components are missing, and are required to run this application. So JavaFX goes on the module path and is asked for by name with --add-modules.

The driver is the opposite case. Your code never names a single MySQL class. At run time, DriverManager.getConnection looks through the class path for jars that announce a JDBC driver and offers the URL to each one until a driver accepts jdbc:mysql:. A plain jar on the class path is exactly what that search finds. Leave the jar out of -cp and the window still opens, but every connection fails with No suitable driver found for jdbc:mysql://localhost:3306/studentdb.

Running from an IDE instead
In IntelliJ IDEA, add the jar as a library (File, Project Structure, Libraries) and put --enable-native-access=javafx.graphics --module-path ... --add-modules javafx.controlsin the run configuration's VM options. In VS Code, add the jar under Referenced Libraries in the Java Projects view, and put the same options in vmArgs in launch.json.

StudentApp: the CRUD program#

This program is a CRUD (Create, Read, Update, Delete) application: it manages student records in a database through JDBC. CRUDnames the four things almost every database program does to its rows. Here “Insert Record” is the Create, “Select Records” the Read, and “Update Record” and “Delete Record” the other two. The fifth button, “Create Table”, is set-up: it builds the empty table the other four work on.

DBConnection: a first version and the one used here#

Every handler begins by asking one small class for a connection. Here is a first version, the kind many tutorials show:

DBConnection.java (first version)
read along
1import java.sql.Connection;
2import java.sql.DriverManager;
3import java.sql.SQLException;
4
5public class DBConnection {
6 public static Connection getConnection() {
7 Connection conn = null;
8 try {
9 String url = "jdbc:mysql://localhost:3306/studentdb";
10 String user = "root";
11 String password = "your_password";
12
13 conn = DriverManager.getConnection(url, user, password);
14 System.out.println("Database Connected Successfully!");
15 } catch (SQLException e) {
16 e.printStackTrace();
17 }
18 return conn;
19 }
20}
Output
On success: prints "Database Connected Successfully!" on the console and returns the Connection.
On failure: prints a stack trace on the console and returns null.

DBConnection is a utility class: it holds no data and exists so that the connection details are written once instead of in every handler. It imports the three JDBC types it uses, Connection, DriverManager and SQLException. Its one method is static, so a handler calls DBConnection.getConnection() without creating a DBConnection object first (static members). The URL jdbc:mysql://localhost:3306/studentdb reads as: JDBC, the MySQL driver, the server on this machine at port 3306, the database studentdb. DriverManager.getConnection(url, user, password) opens the connection and logs in. On success the method prints a confirmation and returns the Connection, which the handlers use to run their insert, update, delete and select statements.

The first version hides the failure
If the connection fails (MySQL stopped, wrong password, database missing), the catch prints the stack trace on the console and the method returns null. The handler then calls conn.createStatement() (or conn.prepareStatement(...)) on null, which throws a NullPointerException. That is not an SQLException, so the handler's catch (SQLException ex) does not catch it either. The window shows nothing, and the real reason sits in a console the user never looks at. The fix is to let the SQLException travel to the handler, which already has a catch for it.
DBConnection.java (used on this page)
read along
1import java.sql.Connection;
2import java.sql.DriverManager;
3import java.sql.SQLException;
4
5public class DBConnection {
6 public static Connection getConnection() throws SQLException {
7 String url = "jdbc:mysql://localhost:3306/studentdb";
8 String user = "root";
9 String password = "your_password";
10
11 Connection conn = DriverManager.getConnection(url, user, password);
12 System.out.println("Database Connected Successfully!");
13 return conn;
14 }
15}
Output
On success: the same message, and the Connection is returned.
On failure: getConnection throws SQLException, the calling handler's
catch (SQLException ex) receives it, and the reason appears in the TextArea.

Two things changed: the method header now says throws SQLException, and the try, the catch and the null are gone. Every handler below already ends in catch (SQLException ex), and that catch now covers a failed connection as well. The handlers compile unchanged with either version, so the programs below need no edits to use it.

StudentApp.java, complete#

The program has three parts: the buttons and the TextArea, then the five handlers, then the layout and main. Put together, it is one file:

StudentApp.java
read along
1import javafx.application.Application;
2import javafx.scene.Scene;
3import javafx.scene.control.*;
4import javafx.scene.layout.VBox;
5import javafx.stage.Stage;
6import java.sql.*;
7
8public class StudentApp extends Application {
9
10 @Override
11 public void start(Stage stage) {
12 Button createBtn = new Button("Create Table");
13 Button insertBtn = new Button("Insert Record");
14 Button updateBtn = new Button("Update Record");
15 Button deleteBtn = new Button("Delete Record");
16 Button selectBtn = new Button("Select Records");
17 TextArea output = new TextArea();
18
19 // --- CREATE TABLE ---
20 createBtn.setOnAction(e -> {
21 try (Connection conn = DBConnection.getConnection();
22 Statement stmt = conn.createStatement()) {
23 String sql = "CREATE TABLE IF NOT EXISTS students (" +
24 "id INT AUTO_INCREMENT PRIMARY KEY, " +
25 "name VARCHAR(50), age INT)";
26 stmt.executeUpdate(sql);
27 output.setText("Table created successfully!");
28 } catch (SQLException ex) {
29 output.setText(ex.getMessage());
30 }
31 });
32
33 // --- INSERT ---
34 insertBtn.setOnAction(e -> {
35 try (Connection conn = DBConnection.getConnection();
36 PreparedStatement ps = conn.prepareStatement(
37 "INSERT INTO students (name, age) VALUES (?, ?)")) {
38 ps.setString(1, "John");
39 ps.setInt(2, 20);
40 ps.executeUpdate();
41 output.setText("Record inserted!");
42 } catch (SQLException ex) {
43 output.setText(ex.getMessage());
44 }
45 });
46
47 // --- UPDATE ---
48 updateBtn.setOnAction(e -> {
49 try (Connection conn = DBConnection.getConnection();
50 PreparedStatement ps = conn.prepareStatement(
51 "UPDATE students SET age=? WHERE name=?")) {
52 ps.setInt(1, 21);
53 ps.setString(2, "John");
54 ps.executeUpdate();
55 output.setText("Record updated!");
56 } catch (SQLException ex) {
57 output.setText(ex.getMessage());
58 }
59 });
60
61 // --- DELETE ---
62 deleteBtn.setOnAction(e -> {
63 try (Connection conn = DBConnection.getConnection();
64 PreparedStatement ps = conn.prepareStatement(
65 "DELETE FROM students WHERE name=?")) {
66 ps.setString(1, "John");
67 ps.executeUpdate();
68 output.setText("Record deleted!");
69 } catch (SQLException ex) {
70 output.setText(ex.getMessage());
71 }
72 });
73
74 // --- SELECT ---
75 selectBtn.setOnAction(e -> {
76 try (Connection conn = DBConnection.getConnection();
77 Statement stmt = conn.createStatement();
78 ResultSet rs = stmt.executeQuery("SELECT * FROM students")) {
79
80 StringBuilder data = new StringBuilder("Students:\n");
81 while (rs.next()) {
82 data.append(rs.getInt("id")).append(" - ")
83 .append(rs.getString("name")).append(" - ")
84 .append(rs.getInt("age")).append("\n");
85 }
86 output.setText(data.toString());
87 } catch (SQLException ex) {
88 output.setText(ex.getMessage());
89 }
90 });
91
92 VBox root = new VBox(10, createBtn, insertBtn, updateBtn, deleteBtn, selectBtn, output);
93 Scene scene = new Scene(root, 400, 300);
94 stage.setTitle("JavaFX JDBC Example");
95 stage.setScene(scene);
96 stage.show();
97 }
98
99 public static void main(String[] args) {
100 launch(args);
101 }
102}
Output
A 400 by 300 window titled "JavaFX JDBC Example": five buttons stacked down the
left edge, 10 pixels apart, and a TextArea filling the space below them.
Nothing reaches MySQL until a button is clicked. Each click prints
"Database Connected Successfully!" on the console and puts one message in the TextArea.

Each handler is a lambda passed to setOnAction, so it runs every time its button is clicked (event handling). Inside, all five follow the JDBC path from the queries lesson:

DriverManagerclass: finds the right drivergetConnection(url, user, password)Connectiona session with the databasecreateStatement()prepareStatement(sql)Statementor PreparedStatement: one SQL commandexecuteQuery()executeUpdate()ResultSetrows from a SELECTintrows changed (0 for CREATE)next(), getInt(), getString()Closing goes the other wayResultSet first, then Statement, then Connection. try-with-resources does it for you.
Every StudentApp handler walks this path once per click. The try-with-resources does the closing, in reverse order.
  • try-with-resources opens the connection and the statement (and, for Select, the ResultSet) and closes them in reverse order when the block ends, whether it ends normally or with an exception. Nothing leaks, however the handler exits.
  • Statement or PreparedStatement. A plain Statement runs SQL that is fixed text, here the CREATE TABLE and the SELECT. A PreparedStatement is used wherever a value goes into the SQL. Each ? is a placeholder, numbered from 1 left to right and filled with setString or setInt before the statement runs. The values travel separately from the SQL text, so a value can never change what the statement does, which is why a PreparedStatement prevents SQL injection.
  • executeUpdate or executeQuery. executeUpdate runs statements that change the database (CREATE, INSERT, UPDATE, DELETE) and returns a row count, which this program ignores. executeQuery runs a SELECT and returns a ResultSet.
  • One message either way. The last line of each try writes a success message into the TextArea. If anything in the try throws an SQLException, the catch writes ex.getMessage() there instead.
ButtonSQLRuns withTextArea on success
Create TableCREATE TABLE IF NOT EXISTS students (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), age INT)stmt.executeUpdate(sql)Table created successfully!
Insert RecordINSERT INTO students (name, age) VALUES (?, ?) with "John", 20ps.executeUpdate()Record inserted!
Update RecordUPDATE students SET age=? WHERE name=? with 21, "John"ps.executeUpdate()Record updated!
Delete RecordDELETE FROM students WHERE name=? with "John"ps.executeUpdate()Record deleted!
Select RecordsSELECT * FROM studentsstmt.executeQuery(...), which returns a ResultSetStudents: and then one line per row

IF NOT EXISTS lets “Create Table” be clicked again without an error, and AUTO_INCREMENT means MySQL picks each new id, which is why the insert names only name and age. The values themselves are fixed in the code: every click on “Insert Record” inserts John, aged 20, because the window has no field to type a name into. The employee program adds the fields. The Select handler builds its text in a StringBuilder that starts with Students: and gains one line per row: the id, the name and the age, separated by " - ".

The last lines build the window. new VBox(10, ...) stacks its children from top to bottom in the order given, with 10 pixels between neighbours. The VBox has no padding, so the controls touch the edges of the window. new Scene(root, 400, 300) makes the content 400 pixels wide and 300 tall. A Button in a VBox keeps the width of its label (it grows only if you call setMaxWidth(Double.MAX_VALUE) on it), while the TextArea stretches across the window and takes whatever height is left.

JavaFX JDBC Example
Create Table
Insert Record
Update Record
Delete Record
Select Records
Students: 1 - John - 20
StudentApp after Create Table, Insert Record and Select Records.

Run it in your head#

Start with an empty studentdb, with no students table yet, and click the buttons in this order. The middle column shows the SQL with its placeholders filled in.

ClickSQL that runsTextArea shows
1. Create TableCREATE TABLE IF NOT EXISTS students (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), age INT)Table created successfully!
2. Insert RecordINSERT INTO students (name, age) VALUES ('John', 20)Record inserted!
3. Select RecordsSELECT * FROM studentsStudents:
1 - John - 20
4. Update RecordUPDATE students SET age=21 WHERE name='John'Record updated!
5. Select RecordsSELECT * FROM studentsStudents:
1 - John - 21
6. Delete RecordDELETE FROM students WHERE name='John'Record deleted!
7. Select RecordsSELECT * FROM studentsStudents: and nothing under it

The last Select still shows Students:, because the StringBuilder starts with that line before the loop runs, and a loop over zero rows adds nothing. A few clicks outside this order are worth predicting too, because they are the ones that catch people out:

  • Insert Record before Create Table. MySQL refuses, and the TextArea shows Table 'studentdb.students' doesn't exist.
  • Create Table a second time. IF NOT EXISTS turns the statement into a no-op, there is no error, and the TextArea still says Table created successfully!
  • Insert Record twice. Two Johns, with ids 1 and 2. “Update Record” then changes both, because WHERE name='John' matches both rows, and “Delete Record” removes both. The messages stay singular.
  • Update or Delete with no John in the table. Nothing changes, yet the TextArea says Record updated! or Record deleted!. The handlers ignore the count that executeUpdate returns, so they cannot tell.
  • Insert Record after a delete. The new John gets the next id, 2 if the deleted row was 1. MySQL does not reuse AUTO_INCREMENT values.

EmployeeManagerApp: input fields and conditions#

The second program manages a table of employees. It has four jobs:

  • Insert an employee. The user types a name, a department and a salary, and the program adds the row.
  • Update salaries by department. Give every employee in one department a new salary.
  • Delete Employee by Condition. Delete employees whose salary is less than a given amount.
  • View by salary. Show every employee whose salary is above a given amount.

Read each job as one SQL statement. Insert is an INSERT with three values. Updating by department is an UPDATE whose WHERE names the department. Deleting below an amount and viewing above one are a DELETE and a SELECT whose WHERE compares the salary with a number. Every value the user supplies becomes a ? in a PreparedStatement. That gives the window its shape: three input fields (name, department, salary), four buttons, and a TextArea for the answers.

The employees table#

The program never creates the employees table itself, so until it exists every statement the program sends fails with Table 'studentdb.employees' doesn't exist. Run this once at the mysql> prompt. It goes in studentdb because the program reuses DBConnection, whose URL still names that database.

employees.sql
read along
1USE studentdb;
2
3CREATE TABLE IF NOT EXISTS employees (
4 id INT AUTO_INCREMENT PRIMARY KEY,
5 name VARCHAR(50),
6 department VARCHAR(50),
7 salary DOUBLE
8);

The columns are the ones the handlers use: name, department and salary, plus an idthat the View handler prints. You could equally give the program a “Create Table” button like StudentApp's.

EmployeeManagerApp.java, complete#

EmployeeManagerApp.java
read along
1import javafx.application.Application;
2import javafx.scene.Scene;
3import javafx.scene.control.*;
4import javafx.scene.layout.VBox;
5import javafx.stage.Stage;
6import java.sql.*;
7
8public class EmployeeManagerApp extends Application {
9
10 @Override
11 public void start(Stage stage) {
12 // Input fields
13 TextField nameField = new TextField();
14 nameField.setPromptText("Enter Name");
15
16 TextField deptField = new TextField();
17 deptField.setPromptText("Enter Department");
18
19 TextField salaryField = new TextField();
20 salaryField.setPromptText("Enter Salary");
21
22 // Buttons
23 Button insertBtn = new Button("Insert Employee");
24 Button updateBtn = new Button("Update Salary by Department");
25 Button deleteBtn = new Button("Delete Employees with Salary < Value");
26 Button viewBtn = new Button("View Employees with Salary > Value");
27
28 TextArea output = new TextArea();
29
30 // --- INSERT EMPLOYEE ---
31 insertBtn.setOnAction(e -> {
32 try (Connection conn = DBConnection.getConnection();
33 PreparedStatement ps = conn.prepareStatement(
34 "INSERT INTO employees (name, department, salary) VALUES (?, ?, ?)")) {
35 ps.setString(1, nameField.getText());
36 ps.setString(2, deptField.getText());
37 ps.setDouble(3, Double.parseDouble(salaryField.getText()));
38 ps.executeUpdate();
39 output.setText("Employee inserted successfully!");
40 } catch (SQLException ex) {
41 output.setText(ex.getMessage());
42 }
43 });
44
45 // --- UPDATE SALARY BY DEPARTMENT ---
46 updateBtn.setOnAction(e -> {
47 try (Connection conn = DBConnection.getConnection();
48 PreparedStatement ps = conn.prepareStatement(
49 "UPDATE employees SET salary=? WHERE department=?")) {
50 ps.setDouble(1, Double.parseDouble(salaryField.getText()));
51 ps.setString(2, deptField.getText());
52 int rows = ps.executeUpdate();
53 output.setText(rows + " employee(s) updated!");
54 } catch (SQLException ex) {
55 output.setText(ex.getMessage());
56 }
57 });
58
59 // --- DELETE EMPLOYEES BY SALARY CONDITION ---
60 deleteBtn.setOnAction(e -> {
61 try (Connection conn = DBConnection.getConnection();
62 PreparedStatement ps = conn.prepareStatement(
63 "DELETE FROM employees WHERE salary<?")) {
64 ps.setDouble(1, Double.parseDouble(salaryField.getText()));
65 int rows = ps.executeUpdate();
66 output.setText(rows + " employee(s) deleted!");
67 } catch (SQLException ex) {
68 output.setText(ex.getMessage());
69 }
70 });
71
72 // --- VIEW EMPLOYEES BY SALARY CONDITION ---
73 viewBtn.setOnAction(e -> {
74 try (Connection conn = DBConnection.getConnection();
75 PreparedStatement ps = conn.prepareStatement(
76 "SELECT * FROM employees WHERE salary>?")) {
77 ps.setDouble(1, Double.parseDouble(salaryField.getText()));
78 ResultSet rs = ps.executeQuery();
79
80 StringBuilder data = new StringBuilder("Employees:\n");
81 while (rs.next()) {
82 data.append(rs.getInt("id")).append(" - ")
83 .append(rs.getString("name")).append(" - ")
84 .append(rs.getString("department")).append(" - ")
85 .append(rs.getDouble("salary")).append("\n");
86 }
87 output.setText(data.toString());
88 } catch (SQLException ex) {
89 output.setText(ex.getMessage());
90 }
91 });
92
93 VBox root = new VBox(10, nameField, deptField, salaryField,
94 insertBtn, updateBtn, deleteBtn, viewBtn, output);
95 Scene scene = new Scene(root, 500, 500);
96 stage.setScene(scene);
97 stage.setTitle("Employee Management - JavaFX JDBC");
98 stage.show();
99 }
100
101 public static void main(String[] args) {
102 launch(args);
103 }
104}
Output
A 500 by 500 window titled "Employee Management - JavaFX JDBC": three empty fields
with grey prompt text, four buttons, and a TextArea. Each click prints
"Database Connected Successfully!" on the console and puts one result in the TextArea,
such as "Employee inserted successfully!" or "2 employee(s) updated!".

Compared with StudentApp, three things are new:

  • Prompt text. setPromptText("Enter Name") shows a grey hint while the field is empty, in place of a separate label. A field that has the keyboard focus hides its prompt, so at launch, with the cursor in the first field, “Enter Name” stays hidden until you click somewhere else.
  • Values from the fields. getText() returns whatever is typed, always as a String. Name and department go straight into setString; the salary is turned into a double by Double.parseDouble before setDouble.
  • Row counts. Update and Delete keep the int that executeUpdate returns and report it, as in 2 employee(s) updated!.
Employee Management - JavaFX JDBC
Enter NameEnter DepartmentEnter Salary
Insert Employee
Update Salary by Department
Delete Employees with Salary < Value
View Employees with Salary > Value
The three fields and the TextArea stretch across the window. The buttons keep the width of their labels.

Four buttons, one salary field#

ButtonSQLFields it readsThe salary field means
Insert EmployeeINSERT INTO employees (name, department, salary) VALUES (?, ?, ?)name, department, salarythe new employee's salary
Update Salary by DepartmentUPDATE employees SET salary=? WHERE department=?salary, departmentthe new salary for everyone in that department
Delete Employees with Salary < ValueDELETE FROM employees WHERE salary<?salarya limit: every row below it is deleted
View Employees with Salary > ValueSELECT * FROM employees WHERE salary>?salarya limit: every row above it is listed

The single salary field plays three roles: the salary to store (Insert and Update), the limit below which rows are deleted (Delete), and the limit above which rows are listed (View). Only Insert reads the name field. Update reads the department and the salary, and Delete and View read the salary alone, whatever the other fields contain. Both comparisons are strict: salary<? and salary>? leave out an employee whose salary equals the number typed, so that employee is neither deleted nor listed.

A worked session#

Four inserts. Start with an empty employeestable. Type each employee below into the three fields and click “Insert Employee”. The TextArea says Employee inserted successfully! four times, and the table holds:

idnamedepartmentsalary
1AnilSales35000
2MeeraSales42000
3RahulHR28000
4DivyaIT39000

Update Sales to 50000. The program never clears the fields, so they still hold Divya, IT and 39000. Change the department to Sales and the salary to 50000, leave Divya in the name field (Update never reads it), and click “Update Salary by Department”. The handler runs UPDATE employees SET salary=50000.0 WHERE department='Sales', two rows match, and the TextArea shows 2 employee(s) updated!. Anil's 35000 and Meera's 42000 both become 50000: SET replaces the value, it does not add to it. A 10 percent raise would be SET salary = salary * 1.10.

idnamedepartmentsalary
1AnilSales50000
2MeeraSales50000
3RahulHR28000
4DivyaIT39000

Delete below 30000. Type 30000in the salary field and click “Delete Employees with Salary < Value”. DELETE FROM employees WHERE salary<30000.0 removes Rahul, the only salary under 30000, and the TextArea shows 1 employee(s) deleted!.

idnamedepartmentsalary
1AnilSales50000
2MeeraSales50000
4DivyaIT39000

View above 40000. Type 40000and click “View Employees with Salary > Value”. SELECT * FROM employees WHERE salary>40000.0returns Anil and Meera. Divya's 39000 is not greater than 40000, so she is left out. The window now looks like this:

Employee Management - JavaFX JDBC
DivyaSales40000
Insert Employee
Update Salary by Department
Delete Employees with Salary < Value
View Employees with Salary > Value
Employees: 1 - Anil - Sales - 50000.0 2 - Meera - Sales - 50000.0
After the last click. The name and department fields still hold Divya and Sales from earlier steps; the View handler reads only the salary.

The TextArea shows 50000.0 where MySQL shows 50000, because getDouble returns a double, and appending a double always writes a decimal point and at least one digit after it. (From ten million upwards Java switches to scientific notation, so a salary of 10000000 would appear as 1.0E7.) The rows arrive in id order here, but SQL promises no order at all without ORDER BY. Add ORDER BY salary DESC to the query if the order matters.

What the first versions get wrong#

Both programs work when every input is sensible and MySQL is up. Real users are less kind. Here are five weak spots, each with its fix, and then one handler with every fix applied.

A salary that is not a number#

Type 45,000 with a comma, or leave the salary empty, and click any of the four employee buttons. Double.parseDouble throws a NumberFormatException, with the message For input string: "45,000" or empty String. That exception is unchecked and is not an SQLException, so the handler's only catch does not match it (checked and unchecked exceptions). The try-with-resources still closes the connection on the way out. Then the exception leaves the handler, and JavaFX prints it on the console under Exception in thread "JavaFX Application Thread". The window stays open and the TextArea keeps its old text, which may well be Employee inserted successfully! from the previous click, so the user believes the insert worked.

All four handlers parse the salary field, so all four need the same fix, one more catch:

The Update handler, with the missing catch
read along
1updateBtn.setOnAction(e -> {
2 try (Connection conn = DBConnection.getConnection();
3 PreparedStatement ps = conn.prepareStatement(
4 "UPDATE employees SET salary=? WHERE department=?")) {
5 ps.setDouble(1, Double.parseDouble(salaryField.getText()));
6 ps.setString(2, deptField.getText());
7 int rows = ps.executeUpdate();
8 output.setText(rows + " employee(s) updated!");
9 } catch (NumberFormatException ex) {
10 output.setText("Salary must be a number, for example 45000");
11 } catch (SQLException ex) {
12 output.setText(ex.getMessage());
13 }
14});
Output
Salary "45,000":                   Salary must be a number, for example 45000
Salary "50000", department "Sales": 2 employee(s) updated!

The two catch blocks can go in either order, because neither exception type is a subclass of the other. A multi-catch, catch (SQLException | NumberFormatException ex), also compiles, but then the user reads For input string: "45,000", which explains less than a sentence you write yourself. Double.parseDouble ignores spaces at either end, so " 45000 " is accepted. Commas, currency signs and letters are not.

A connection that is null#

This is the DBConnection problem from earlier, seen from the window. With the first version, the four failures below look identical to the user: they click, and nothing happens, while the console collects two stack traces per click. With the version that throws SQLException, each failure reaches the handler's catch and the TextArea names the cause:

What is wrongTextArea text with the throws SQLException version
MySQL is not runningCommunications link failure, followed by a sentence saying the driver has not received any packets from the server
Wrong password in DBConnectionAccess denied for user 'root'@'localhost' (using password: YES)
The database was never createdUnknown database 'studentdb'
The driver jar is missing from -cpNo suitable driver found for jdbc:mysql://localhost:3306/studentdb

These four cover most first-run failures of a JavaFX and JDBC program, and with the better DBConnection the window tells you which one you have.

Empty fields#

An empty TextField returns an empty string, not null, and MySQL is happy to store an empty string. Fill in only the salary, click “Insert Employee”, and you get an employee with no name and no department. Leave the department empty and click “Update Salary by Department”, and the WHERE becomes department='', which matches nothing: 0 employee(s) updated!. Check the fields before opening a connection, the way the registration form in form creation checked its fields before accepting them:

Checking the fields first (top of the Insert handler)
read along
1String name = nameField.getText().trim();
2String dept = deptField.getText().trim();
3if (name.isEmpty() || dept.isEmpty()) {
4 output.setText("Enter a name and a department first.");
5 return;
6}
Output
Name or department empty, or only spaces: Enter a name and a department first.
Nothing is sent to MySQL.

return ends the lambda early, so the handler never reaches the database. Trimming first means a field that holds only spaces counts as empty, and a stray space after Sales cannot spoil the department match.

A delete with no way back#

“Delete Employees with Salary < Value” removes every row below the number without a question. One slip, 300000 instead of 30000, deletes most of the company, and the delete is permanent the moment it runs, because JDBC commits every statement straight away unless told otherwise. Ask first, with a confirmation Alert:

The Delete handler, with a confirmation
read along
1deleteBtn.setOnAction(e -> {
2 String text = salaryField.getText().trim();
3 double limit;
4 try {
5 limit = Double.parseDouble(text);
6 } catch (NumberFormatException ex) {
7 output.setText("Type a salary limit first, for example 30000");
8 return;
9 }
10
11 Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
12 confirm.setHeaderText(null);
13 confirm.setContentText("Delete every employee earning less than " + text + "?");
14 if (confirm.showAndWait().orElse(ButtonType.CANCEL) != ButtonType.OK) {
15 output.setText("Nothing was deleted.");
16 return;
17 }
18
19 try (Connection conn = DBConnection.getConnection();
20 PreparedStatement ps = conn.prepareStatement(
21 "DELETE FROM employees WHERE salary<?")) {
22 ps.setDouble(1, limit);
23 int rows = ps.executeUpdate();
24 output.setText(rows + " employee(s) deleted!");
25 } catch (SQLException ex) {
26 output.setText(ex.getMessage());
27 }
28});
Output
Salary field "30000": a Confirmation dialog asks
"Delete every employee earning less than 30000?"
OK:                              1 employee(s) deleted!
Cancel, or closing the dialog:   Nothing was deleted.
Salary field "abc":              Type a salary limit first, for example 30000
Confirmation
Delete every employee earning less than 30000?
OKCancel
The dialog showAndWait() puts up. Nothing is deleted until OK is clicked.

The number is parsed first, so a typo never opens a dialog or a connection. showAndWait() shows the dialog and pauses the handler until the user answers, then returns the button pressed, wrapped in an Optional. orElse(ButtonType.CANCEL) treats anything other than a real answer as Cancel, so only a click on OK goes on to the DELETE. The window does not freeze while the dialog waits: showAndWait keeps the FX thread handling events for the dialog.

Messages that tell the truth#

StudentApp says Record updated! and Record deleted! even when no row matched, because it throws away the number executeUpdate returns. EmployeeManagerApp already reports the count. StudentApp needs only a few more lines:

StudentApp's Update handler, reporting the count
read along
1updateBtn.setOnAction(e -> {
2 try (Connection conn = DBConnection.getConnection();
3 PreparedStatement ps = conn.prepareStatement(
4 "UPDATE students SET age=? WHERE name=?")) {
5 ps.setInt(1, 21);
6 ps.setString(2, "John");
7 int rows = ps.executeUpdate();
8 if (rows == 0) {
9 output.setText("No student called John, nothing updated.");
10 } else {
11 output.setText(rows + " record(s) updated.");
12 }
13 } catch (SQLException ex) {
14 output.setText(ex.getMessage());
15 }
16});
Output
One John in the table:  1 record(s) updated.
Two Johns:              2 record(s) updated.
No John:                No student called John, nothing updated.

Create Table needs a different fix. With IF NOT EXISTS the statement succeeds whether or not it created anything, and executeUpdate returns 0 either way, so the honest message is one that is true in both cases, such as Table students is ready.

All of it together: a careful insert#

Here is the employee Insert handler with every fix applied. It is the pattern for any handler that takes input: read and check everything first, connect last, and finish with a message either way.

A careful Insert handler for EmployeeManagerApp
read along
1insertBtn.setOnAction(e -> {
2 // 1. Read and check every input before touching the database.
3 String name = nameField.getText().trim();
4 String dept = deptField.getText().trim();
5 String salaryText = salaryField.getText().trim();
6
7 if (name.isEmpty() || dept.isEmpty() || salaryText.isEmpty()) {
8 output.setText("Enter a name, a department and a salary.");
9 return;
10 }
11
12 double salary;
13 try {
14 salary = Double.parseDouble(salaryText);
15 } catch (NumberFormatException ex) {
16 output.setText("Salary must be a number, for example 45000.");
17 return;
18 }
19 if (salary < 0) {
20 output.setText("Salary cannot be negative.");
21 return;
22 }
23
24 // 2. Only now connect, and report what happened either way.
25 try (Connection conn = DBConnection.getConnection();
26 PreparedStatement ps = conn.prepareStatement(
27 "INSERT INTO employees (name, department, salary) VALUES (?, ?, ?)")) {
28 ps.setString(1, name);
29 ps.setString(2, dept);
30 ps.setDouble(3, salary);
31 ps.executeUpdate();
32 output.setText("Inserted " + name + " (" + dept + ", " + salary + ")");
33 nameField.clear();
34 deptField.clear();
35 salaryField.clear();
36 } catch (SQLException ex) {
37 output.setText("Database error: " + ex.getMessage());
38 }
39});
Output
All fields empty:        Enter a name, a department and a salary.
Salary "45,000":         Salary must be a number, for example 45000.
Salary "-500":           Salary cannot be negative.
MySQL stopped:           Database error: Communications link failure ...
Anil, Sales, 35000:      Inserted Anil (Sales, 35000.0), and the three fields clear.
Key idea

Every click should end with a message in the window, whatever happened: success, a typo, an empty field or a dead database. The console is for you. The TextArea is for the user.

Keeping the window responsive#

Every handler on this page runs on the JavaFX Application Thread. That one thread also redraws the window and delivers every click, key press and resize. While a handler is busy, none of that happens. The window is frozen until the handler returns.

Against MySQL on your own machine, a handler finishes in a fraction of a second and nobody notices. The freeze becomes visible when the database is slow to answer: a server across a network that is down or behind a firewall (the connection attempt can wait tens of seconds before giving up), a SELECT over a very large table, or an UPDATEwaiting for a row that another program has locked (MySQL waits up to 50 seconds by default). For the whole wait, the clicked button stays drawn pressed in, the fields and the other buttons do not react, and after a few seconds Windows adds “(Not Responding)” to the title bar while macOS shows its spinning wait cursor.

Moving the query onto a Task#

The fix is to do the database work on a second thread and come back to the FX thread only to show the result. JavaFX's tool for this is javafx.concurrent.Task. A Task is a Runnable, so a plain Thread can run it, exactly like the threads in the threads lesson, and it also carries the result back to the FX thread. Here is StudentApp's Select handler, converted. It needs one more import, import javafx.concurrent.Task;.

The Select handler, off the FX thread
read along
1selectBtn.setOnAction(e -> {
2 Task<String> task = new Task<>() {
3 @Override
4 protected String call() throws Exception {
5 // Background thread: database work only, no controls.
6 try (Connection conn = DBConnection.getConnection();
7 Statement stmt = conn.createStatement();
8 ResultSet rs = stmt.executeQuery("SELECT * FROM students")) {
9
10 StringBuilder data = new StringBuilder("Students:\n");
11 while (rs.next()) {
12 data.append(rs.getInt("id")).append(" - ")
13 .append(rs.getString("name")).append(" - ")
14 .append(rs.getInt("age")).append("\n");
15 }
16 return data.toString();
17 }
18 }
19 };
20
21 // Both of these run back on the FX thread, so they may touch controls.
22 task.setOnSucceeded(ev -> {
23 output.setText(task.getValue());
24 selectBtn.setDisable(false);
25 });
26 task.setOnFailed(ev -> {
27 output.setText(task.getException().getMessage());
28 selectBtn.setDisable(false);
29 });
30
31 selectBtn.setDisable(true);
32 output.setText("Loading...");
33
34 Thread worker = new Thread(task);
35 worker.setDaemon(true);
36 worker.start();
37});
Output
Click Select Records: the button greys out and the TextArea says "Loading..." at once.
While the query runs, the window can be moved and resized and the other buttons still respond.
When the rows arrive, the TextArea shows "Students:" and one line per row,
and the button comes back. If the query fails, the TextArea shows the error message.
  1. 1call() is the only part that runs on the background thread. It does the JDBC work and returns the finished text. It has no catch: whatever it throws is kept by the task. The task itself is an anonymous class, as in inner classes.
  2. 2setOnSucceeded runs on the FX thread after call() has returned. task.getValue() is the string that call() returned.
  3. 3setOnFailed runs on the FX thread if call() threw. task.getException() is what it threw, for example the SQLException from a failed connection.
  4. 4Before starting the thread, the handler disables the button and shows Loading.... The window now stays responsive, so the user could click again; the disabled button stops a second query from starting while the first is still running.
  5. 5worker.start() runs call() on the new thread. It is start(), not run(), as with every thread: run() would do the work on the FX thread and freeze the window again. setDaemon(true) lets the program exit when the window closes, even if a query is still waiting.

The inner lambdas take ev rather than e, because a lambda parameter may not reuse the name of a variable that is already in scope, and e belongs to the outer lambda.

Key idea

Slow work goes on a background thread, controls stay on the FX thread, and a Task carries the result from one to the other.

The rule: controls belong to the FX thread#

Never touch a control inside call()
call() runs on the background thread. Calling output.setText(...) there sometimes throws IllegalStateException: Not on FX application thread, and sometimes seems to work and leaves the screen in a broken state later. Both are bugs. Return the value from call() and use it in setOnSucceeded.

Task fits a job that produces one result. When a thread you started yourself needs to change a control once, use Platform.runLater from javafx.application.Platform, as in Platform.runLater(() -> output.setText("Done")). It puts the lambda in the FX thread's queue, and the FX thread runs it as soon as it is free. Task uses the same mechanism to run your setOnSucceeded and setOnFailed handlers on the FX thread.

Writing one from a blank page#

A typical task gives you a table and three or four operations, like the employee example. Write the parts in this order and nothing gets left out:

  1. 1Imports. javafx.application.Application, javafx.scene.Scene, javafx.scene.control.*, javafx.scene.layout.VBox, javafx.stage.Stage and java.sql.*. The same six lines in every such program.
  2. 2DBConnection. A class with public static Connection getConnection() throws SQLException that returns DriverManager.getConnection(url, user, password), the URL being jdbc:mysql://localhost:3306/ followed by the database name.
  3. 3The table. Write the CREATE TABLE statement you are assuming, so the column names in your SQL have something to match.
  4. 4The class. public class LibraryApp extends Application, containing @Override public void start(Stage stage).
  5. 5Controls. One TextField per input, each with setPromptText, one Button per operation, and one TextArea output.
  6. 6Layout. VBox root = new VBox(10, ...), listing the fields, the buttons and the TextArea in screen order.
  7. 7One handler per button. btn.setOnAction(e -> { ... }) holding a try-with-resources that opens the connection and a PreparedStatement, fills each ? from 1 upwards, runs the statement and writes the result to output. Then catch (NumberFormatException ex) if you parsed a number, and catch (SQLException ex) with output.setText(ex.getMessage()).
  8. 8The right execute method. INSERT, UPDATE and DELETE use executeUpdate() and report the count it returns. SELECT uses executeQuery() and a while (rs.next()) loop that appends each row to a StringBuilder.
  9. 9Scene and stage. new Scene(root, 500, 500), width first, then stage.setTitle(...), stage.setScene(scene) and stage.show().
  10. 10main. public static void main(String[] args) { launch(args); }
Where these programs usually go wrong
  • executeQuery for an INSERT, UPDATE or DELETE, or executeUpdate for a SELECT. The driver rejects both with an SQLException.
  • Reading rs.getString(...) before the first rs.next(). The cursor starts before the first row.
  • Numbering placeholders from 0. The first ? is 1.
  • Parsing a number with only catch (SQLException ex) underneath.
  • Writing new Scene(root, height, width). The width comes first.
  • Forgetting extends Application or launch(args).

Try it

Answer each one in your head or on paper first, then open the answer.

1
MySQL is not running, and StudentApp uses the first DBConnection. The user clicks “Select Records”. What appears on the console, what appears in the window, and why does the handler's catch (SQLException ex) never run?
2
The students table has just been created and is empty. The user clicks Insert Record, Insert Record, Update Record, Select Records, Delete Record, Insert Record, Select Records. Write the TextArea text after each of the two Selects.
3
In EmployeeManagerApp, straight after a successful insert, the user types 45,000 in the salary field and clicks “Insert Employee” again. What happens, and what does the user see? Which other buttons fail the same way, and what is the smallest fix?
4
The employees table holds Anil 50000, Meera 50000, Rahul 28000 and Divya 39000 (ids 1 to 4). The user types 39000 in the salary field, clicks “Delete Employees with Salary < Value”, then “View Employees with Salary > Value”. What does the TextArea show after each click, and what happens to Divya?
5
Add a “Search by Name” button to StudentApp, with a TextField for the name. Its handler lists only the students whose name equals what was typed, in the same format as Select Records. Write the new controls, the handler, and the changed VBox line.
6
A student moves the Select handler into a Task but writes output.setText(data.toString()) at the end of call() and returns null. On some runs it throws an IllegalStateException, on others it seems to work. Explain both behaviours, and fix it.

Practice

Work through these on your own. Each one builds on this page.

1
Write LibraryApp for a books table with three columns, isbn (the primary key), title and author. The window has fields for ISBN, title and author and three buttons: “Add Book”, “Search by Author” (lists every book by the author typed) and “Delete by ISBN” (with a confirmation dialog). Include the CREATE TABLE statement, and make every button report what happened, including a message such as No book with ISBN 978-0-13-468599-1 when Delete matches nothing.
2
Give StudentApp two text fields, Name and Age, and make Insert Record, Update Record and Delete Record use what is typed instead of John, 20 and 21. Update changes the age of the student whose name is typed. Every handler must check its input and report the real count, for example No student called Mary.
3
Replace the TextArea in EmployeeManagerApp with a TableView that shows id, name, department and salary in four columns, and refill it after every button click.
4
Add a “Department Summary” button to EmployeeManagerApp that shows one line per department with the number of employees and their average salary, using a single SQL query. Then write down exactly what it should print for the worked session's table after the update step: Anil and Meera on 50000 in Sales, Rahul on 28000 in HR, Divya on 39000 in IT.
5
Make EmployeeManagerApp safe to hand to someone else: check every field before connecting, catch NumberFormatException in every handler, ask for confirmation before Delete, and move View onto a Task. Then see the freeze for yourself. In the original View handler, run SELECT SLEEP(3) on the connection before the real query and try to drag or resize the window during the wait. Do the same inside your Task version and describe the difference in two or three sentences.

Spotted a mistake, or a part that did not make sense? Report it by email