☕ Java

Metadata

JDBC exposes two distinct families of metadata that describe different things: DatabaseMetaData, obtained from a Connection, describes the database server itself — its product, version, supported features, and schema structure (tables, columns, keys, indexes) — while ResultSetMetaData, obtained from a ResultSet, describes only the shape of one particular query's results — the number of columns, their names, types, and nullability. This entry covers DatabaseMetaData's capability-discovery and schema-introspection roles, ResultSetMetaData's per-query column description role, and the practical uses of metadata in tooling and generic, schema-agnostic application code.

DatabaseMetaData — Describing the Database and Driver

conn.getMetaData() returns a DatabaseMetaData object that answers questions about the database server and driver as a whole, independent of any specific query — what product and version is running (getDatabaseProductName(), getDatabaseProductVersion()), what driver and version is in use (getDriverName(), getDriverVersion()), and a large number of supportsXxx() boolean methods describing optional JDBC and SQL features the combination of driver and database actually implements, such as supportsTransactions(), supportsBatchUpdates(), supportsSavepoints(), or supportsTransactionIsolationLevel(level). These capability checks matter because the JDBC specification deliberately allows many features to be optional from a driver's perspective, so code intended to run portably across more than one database should check support before relying on a given feature rather than assuming every driver implements every optional capability identically. DatabaseMetaData also exposes limits and naming conventions that vary by database — getMaxColumnsInTable(), getMaxConnections(), getIdentifierQuoteString() (the character used to quote identifiers containing special characters or reserved words, which differs between databases), and getSQLKeywords() (words reserved by this specific database beyond the standard SQL keyword set) — information primarily useful to tools and frameworks that need to generate valid SQL or UI dynamically across multiple target databases, rather than to typical application code written against one known database.
Java
import java.sql.*;

public class DatabaseMetadataBasics {
    public static void main(String[] args) throws SQLException {
        try (Connection conn = DriverManager.getConnection(
                "jdbc:postgresql://localhost:5432/storedb", "app_user", "secret")) {

            DatabaseMetaData meta = conn.getMetaData();

            // ── Identity of the database and driver ────────────────────────
            System.out.println("Database: " + meta.getDatabaseProductName()
                    + " " + meta.getDatabaseProductVersion());
            System.out.println("Driver: " + meta.getDriverName()
                    + " " + meta.getDriverVersion());
            System.out.println("JDBC version: " + meta.getJDBCMajorVersion()
                    + "." + meta.getJDBCMinorVersion());

            // ── Capability checks — relevant before relying on optional features ──
            System.out.println("Supports transactions: " + meta.supportsTransactions());
            System.out.println("Supports batch updates: " + meta.supportsBatchUpdates());
            System.out.println("Supports savepoints: " + meta.supportsSavepoints());
            System.out.println("Supports SERIALIZABLE isolation: "
                    + meta.supportsTransactionIsolationLevel(Connection.TRANSACTION_SERIALIZABLE));

            // ── Limits and naming conventions — mainly relevant to generic tooling ──
            System.out.println("Max columns in table: " + meta.getMaxColumnsInTable());
            System.out.println("Max connections: " + meta.getMaxConnections());
            System.out.println("Identifier quote string: '" + meta.getIdentifierQuoteString() + "'");
        }
    }
}

DatabaseMetaData — Introspecting Schema Structure

Beyond capability discovery, DatabaseMetaData provides programmatic access to the database's actual schema — every method here returns a ResultSet whose columns are themselves standardized by the JDBC specification (e.g. getTables() always returns columns named TABLE_CAT, TABLE_SCHEM, TABLE_NAME, TABLE_TYPE, and others, regardless of which database is being introspected), which is what lets a single piece of code introspect the schema of any JDBC-compliant database uniformly. getTables(catalog, schemaPattern, tableNamePattern, types) lists tables (and optionally views) matching the given filters, where null or "%" wildcards broaden the match; getColumns() similarly lists column definitions for matching tables, including each column's name, SQL type, size, and nullability. Relationship and constraint information is available through getPrimaryKeys() (the primary key column(s) for a given table) and getImportedKeys()/getExportedKeys() (foreign key relationships pointing into or out of a given table, respectively), which is exactly the information schema-migration tools, ORMs, and database-diagramming utilities need to reconstruct a data model without being told it explicitly. Because this introspection happens through ordinary ResultSet objects, the same iteration and column-reading patterns used for regular query results apply directly — the only difference is that the columns being read describe schema elements rather than application data.
Java
import java.sql.*;

public class SchemaIntrospection {
    public static void main(String[] args) throws SQLException {
        try (Connection conn = DriverManager.getConnection(
                "jdbc:postgresql://localhost:5432/storedb", "app_user", "secret")) {

            DatabaseMetaData meta = conn.getMetaData();

            // ── List all user tables (filtering out system tables/views) ───
            try (ResultSet tables = meta.getTables(null, "public", "%", new String[]{"TABLE"})) {
                while (tables.next()) {
                    System.out.println("Table: " + tables.getString("TABLE_NAME"));
                }
            }

            // ── Describe every column of a specific table ──────────────────
            try (ResultSet columns = meta.getColumns(null, "public", "products", "%")) {
                while (columns.next()) {
                    System.out.printf("  %s: %s (nullable=%s)%n",
                            columns.getString("COLUMN_NAME"),
                            columns.getString("TYPE_NAME"),
                            columns.getString("IS_NULLABLE"));
                }
            }

            // ── Primary key columns ─────────────────────────────────────────
            try (ResultSet pk = meta.getPrimaryKeys(null, "public", "products")) {
                while (pk.next()) {
                    System.out.println("PK column: " + pk.getString("COLUMN_NAME"));
                }
            }

            // ── Foreign keys referencing other tables FROM this table ──────
            try (ResultSet fk = meta.getImportedKeys(null, "public", "orders")) {
                while (fk.next()) {
                    System.out.println(fk.getString("FKCOLUMN_NAME") + " -> "
                            + fk.getString("PKTABLE_NAME") + "." + fk.getString("PKCOLUMN_NAME"));
                }
            }
        }
    }
}

ResultSetMetaData — Describing One Query's Result Shape

Where DatabaseMetaData describes the database as a whole, ResultSetMetaData — obtained via rs.getMetaData() on an already-executed ResultSet — describes only the columns returned by that one specific query: how many there are (getColumnCount()), each one's name and label (getColumnName() vs. getColumnLabel(), which differ when the query used an AS alias), its SQL type as both a numeric java.sql.Types constant (getColumnType()) and a human-readable name (getColumnTypeName()), and whether it's nullable, auto-incrementing, or case-sensitive. This is fundamentally different in scope from DatabaseMetaData.getColumns(), which describes a table's columns as declared in the schema — a query's result columns might be computed expressions, joins across tables, or aggregates that have no single corresponding schema column at all, so ResultSetMetaData is specifically about what came back from this query, not about schema structure in general. The most common practical use is writing generic code that processes the results of an arbitrary, not-known-in-advance query — a database browser, a CSV/JSON export utility, a generic reporting tool — where the code has no compile-time knowledge of what columns a given query will return and must discover the column count and types at runtime before it can meaningfully read values out of each row. For ordinary application code that already knows exactly what columns a specific query returns, reading rs.getMetaData() before processing rows is unnecessary overhead; it earns its place specifically when the query (or its column set) is not fixed at compile time.
Java
import java.sql.*;

public class ResultSetMetadataGenericExport {
    // Generic enough to handle ANY query's result shape, discovered at runtime
    public static void exportAsCsv(Connection conn, String anySql) throws SQLException {
        try (Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(anySql)) {

            ResultSetMetaData meta = rs.getMetaData();
            int columnCount = meta.getColumnCount();

            // Header row — column labels respect any AS alias used in the query
            StringBuilder header = new StringBuilder();
            for (int i = 1; i <= columnCount; i++) {
                if (i > 1) header.append(",");
                header.append(meta.getColumnLabel(i));
            }
            System.out.println(header);

            // Data rows — read every column generically, regardless of type,
            // since the actual SQL/column structure isn't known ahead of time
            while (rs.next()) {
                StringBuilder row = new StringBuilder();
                for (int i = 1; i <= columnCount; i++) {
                    if (i > 1) row.append(",");
                    Object value = rs.getObject(i);
                    row.append(value == null ? "" : value.toString());
                }
                System.out.println(row);
            }
        }
    }

    // Works identically whether the query is a simple SELECT or a complex
    // join/aggregate — the method never assumes a specific column layout
    public static void main(String[] args) throws SQLException {
        try (Connection conn = DriverManager.getConnection(
                "jdbc:postgresql://localhost:5432/storedb", "app_user", "secret")) {
            exportAsCsv(conn, "SELECT id, name, price FROM products");
            exportAsCsv(conn, "SELECT category, COUNT(*) AS item_count, AVG(price) AS avg_price "
                    + "FROM products GROUP BY category");
        }
    }
}

ParameterMetaData — Introspecting a PreparedStatement's Own Parameters

Less commonly used than the other two metadata interfaces, ps.getParameterMetaData() returns a ParameterMetaData object describing the placeholders of an already-prepared PreparedStatement — how many parameters it expects (getParameterCount()), and, to whatever extent the driver and database can determine it ahead of actual execution, each parameter's expected SQL type, precision, and nullability. This is most useful for tooling that needs to build a UI or validation layer around a parameterized query without already knowing its parameter types from elsewhere in the application — for instance, a SQL query tool that lets a user type a query containing '?' placeholders and then dynamically renders an appropriately-typed input field for each one. Support for ParameterMetaData varies more across drivers than ResultSetMetaData does, since determining a parameter's type ahead of binding a value to it sometimes requires the database to actually analyze and partially plan the statement before any execution happens at all — something not every database/driver combination supports equally well, and some drivers return generic or best-guess type information rather than a fully reliable answer. Because of this variability, application code that already knows what types its own parameters should be (the overwhelmingly common case, since the application wrote the parameterized SQL itself) generally has no need to call getParameterMetaData() at all; it matters primarily for genuinely generic tooling that processes arbitrary, not-statically-known SQL.
Java
import java.sql.*;

public class ParameterMetadataExample {
    public static void describeParameters(Connection conn, String parameterizedSql) throws SQLException {
        try (PreparedStatement ps = conn.prepareStatement(parameterizedSql)) {
            ParameterMetaData pmeta = ps.getParameterMetaData();
            int count = pmeta.getParameterCount();

            System.out.println("Query expects " + count + " parameter(s):");
            for (int i = 1; i <= count; i++) {
                try {
                    String typeName = pmeta.getParameterTypeName(i);
                    int nullable = pmeta.isNullable(i);
                    System.out.printf("  param %d: type=%s nullable=%s%n", i, typeName,
                            nullable == ParameterMetaData.parameterNullable);
                } catch (SQLException e) {
                    // Some drivers cannot determine type info for a given parameter
                    // ahead of binding/execution — handle gracefully rather than failing
                    System.out.println("  param " + i + ": type unavailable from this driver");
                }
            }
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection conn = DriverManager.getConnection(
                "jdbc:postgresql://localhost:5432/storedb", "app_user", "secret")) {
            describeParameters(conn,
                    "SELECT * FROM products WHERE category = ? AND price < ?");
        }
    }
}

Related Topics in Java Database Connectivity (JDBC)

JDBC Architecture
JDBC (Java Database Connectivity) is the standard Java API for connecting to and executing operations against relational databases, defined as a set of interfaces in java.sql and javax.sql that database vendors implement with concrete driver classes. Its architecture is deliberately layered to decouple application code from any specific database product: applications code against interfaces like Connection, Statement, and ResultSet, while a pluggable driver underneath translates those calls into the vendor-specific network protocol the database actually understands. This entry covers the two-layer (API/driver) architecture, the four historical driver types, the role of the JDBC driver manager and connection pooling layers, and how the pieces fit together at runtime.
JDBC Drivers
A JDBC driver is the vendor-specific library that implements the java.sql/javax.sql interfaces for a particular database product, translating standard JDBC calls into that database's native network protocol. Choosing, loading, registering, and configuring the correct driver is a prerequisite for any JDBC code to run, and the mechanics of how a driver gets discovered and instantiated have evolved significantly since JDBC's introduction. This entry covers driver registration via the Service Provider Interface (SPI) versus the older explicit Class.forName() approach, JDBC URL syntax and structure, driver-specific configuration properties, and how to choose and manage driver dependencies in a real project.
DriverManager
java.sql.DriverManager is the original, classic entry point into JDBC: a static factory class that maintains a registry of available Driver implementations and hands back a live Connection when given a JDBC URL and credentials. It predates the javax.sql.DataSource abstraction and connection pooling, and while it remains fully functional and is still commonly used for simple applications, scripts, and tests, production server-side code generally prefers DataSource-based connection pooling for the reasons covered below. This entry covers how DriverManager locates and selects a driver, its core methods, login timeout and logging configuration, and where it fits (and doesn't fit) in modern application architecture.
Connection
java.sql.Connection represents a single session with a specific database — the live link through which all statements, transactions, and metadata queries flow, and the factory from which Statement, PreparedStatement, and CallableStatement objects are created. A Connection is a relatively expensive, stateful resource that wraps an underlying network socket and server-side session, which is why its lifecycle, transaction settings, and proper closing matter so much in practice. This entry covers obtaining and closing connections, transaction control (auto-commit, commit, rollback, savepoints, isolation levels), creating statements from a connection, and connection metadata and health checks.