Data Source V2

Overview

Data Source V2 (DSV2) is Spark’s extensible API for integrating external data systems. It is a set of Java interfaces in the org.apache.spark.sql.connector package that allow connectors to plug into Spark’s query planning and execution. Connectors opt in to features and optimizations — such as filter pushdown, columnar reads, or catalog support — by implementing specific mix-in interfaces, so a minimal connector can add capabilities incrementally. Notable users include:

Compared to the earlier Data Source V1 API, DSV2 offers:

Feature Description
Java API The connector interfaces are pure Java (org.apache.spark.sql.connector), removing the Scala dependency that DSV1 required. A Python Data Source API (pyspark.sql.datasource) is also available as a wrapper for lightweight connectors written entirely in Python. Native data sources can be written entirely in native code, such as Rust or C++.
Catalog integration Connectors can expose namespaces, tables, views, and functions natively through Spark SQL.
Operator pushdown Connectors can accept pushed-down filters, required columns, aggregates, limits, offsets, and more.
Report partitioning and ordering Connectors can report the physical layout of data so that Spark can avoid unnecessary shuffles and sorts.
Requested distribution and ordering Write connectors can request that Spark repartition and sort incoming data before writing, enabling optimized data layouts such as clustering or Z-ordering.
Columnar reads Connectors can return data in columnar batches for vectorized processing.
Row-level DML Connectors can natively support DELETE, UPDATE, and MERGE INTO operations through dedicated interfaces.
Streaming support A unified Table abstraction supports batch, micro-batch, and continuous processing through the same interfaces.

Entry Points

There are two ways to plug a data source into Spark:

Entry Point Use Case
TableProvider The simpler entry point, for sources that identify a table by options (e.g. a file path or Kafka topic) rather than through a catalog. Can implement SupportsCatalogOptions to map reader and writer options to a table in a catalog.
CatalogPlugin Typically used by external data sources that manage their own catalog of namespaces, tables, and optionally views and functions (e.g. Iceberg, Delta Lake). Registered via spark.sql.catalog.<name>=com.example.MyCatalog.

TableProvider

TableProvider returns a Table given a set of options. Implementations must have a public no-arg constructor.

The key methods are:

Method Description
inferSchema(options) Infer the table’s schema from the given options
inferPartitioning(options) Optionally infer the table’s partitioning
getTable(schema, partitioning, properties) Return a Table for the resolved schema and partitioning

A TableProvider can also implement SupportsCatalogOptions so that DataFrameReader.load and DataFrameWriter.save resolve the user-supplied options to an identifier in a catalog (the session catalog unless the options name another), which enables existence checks and create-on-save for those calls.

CatalogPlugin

CatalogPlugin is a marker interface for catalog implementations. After instantiation, Spark calls initialize(name, options) with the catalog name and all configuration properties that share the prefix spark.sql.catalog.<name>..

A catalog adds capabilities by mixing in additional catalog interfaces:

Interface Capability
TableCatalog List, load, create, alter, and drop tables
StagingTableCatalog Atomic create-table-as-select / replace-table-as-select
SupportsNamespaces Create, alter, drop, and list namespaces
ViewCatalog List, load, create, replace, rename, and drop views
RelationCatalog Tables and views that share one identifier namespace (required when a catalog exposes both)
FunctionCatalog List and load functions
ProcedureCatalog Load and list stored procedures

CatalogExtension is a special variant that wraps Spark’s built-in session catalog and can be used to add custom behavior while delegating to the default implementation.

Catalog Interfaces

The interfaces below are mix-ins that a CatalogPlugin implements to expose specific categories of metadata operations through Spark SQL.

TableCatalog

TableCatalog extends CatalogPlugin and provides methods for Table lifecycle management:

Method Description
listTables(namespace) List tables in a namespace
loadTable(ident) Load a table by identifier
createTable(ident, tableInfo) Create a new table from a TableInfo carrying columns, partitioning, properties, constraints, and a requested write distribution and ordering
alterTable(ident, changes...) Alter a table’s schema, properties, or constraints
dropTable(ident) Drop a table
renameTable(oldIdent, newIdent) Rename a table

Spark calls createTable(ident, tableInfo) (since 4.1.0) for CREATE TABLE, REPLACE TABLE, and CTAS, so a new catalog overrides that form. Its default forwards to the deprecated createTable(ident, columns, partitions, properties) and then createTable(ident, schema, partitions, properties) (deprecated since 4.1.0 and 3.4.0), which keeps older catalogs working but drops the table’s constraints. The default rejects a requested write distribution and ordering. A catalog that reports SUPPORTS_CREATE_TABLE_WITH_WRITE_DISTRIBUTION_AND_ORDERING overrides createTable(ident, tableInfo), and for a staging catalog also the three stage* forms below. A StagingTableCatalog is reached through its own methods instead, except for a plain CREATE TABLE, which still arrives at createTable.

StagingTableCatalog

StagingTableCatalog extends TableCatalog and enables atomic CREATE TABLE AS SELECT and REPLACE TABLE AS SELECT. It returns a StagedTable whose changes only become visible when commitStagedChanges() is called. If the write fails, the catalog remains unchanged. Spark reaches these paths through stageCreate(ident, tableInfo), stageReplace(ident, tableInfo), and stageCreateOrReplace(ident, tableInfo) rather than createTable, so a staging catalog overrides those three in addition to the TableCatalog methods. Their defaults forward to the deprecated forms, which throw, and reject a requested write distribution and ordering.

SupportsNamespaces

SupportsNamespaces adds namespace (database/schema) management:

Method Description
listNamespaces() List child namespaces
createNamespace(namespace, metadata) Create a namespace
alterNamespace(namespace, changes...) Alter namespace properties
dropNamespace(namespace, cascade) Drop a namespace

ViewCatalog

ViewCatalog extends CatalogPlugin and provides methods for view lifecycle management. View DDL (CREATE/REPLACE/ALTER/DROP/SHOW VIEWS) and reading from a view (SELECT) are resolved and executed against the catalog. Connectors that expose both tables and views in a single shared identifier namespace implement RelationCatalog (since 4.2.0), which extends both TableCatalog and ViewCatalog and adds loadRelation(ident) so a lookup that may be either kind resolves in one round trip. A catalog that implements TableCatalog and ViewCatalog directly, without RelationCatalog, is rejected when it is initialized. A view’s metadata is represented by View.

Method Description
listViews(namespace) List views in a namespace
loadView(ident) Load a view’s View by identifier
viewExists(ident) Test whether a view exists (has a default implementation)
invalidateView(ident) Invalidate cached metadata for a view (has a default implementation)
createView(ident, info) Create a new view
replaceView(ident, info) Atomically replace an existing view (ALTER VIEW ... AS)
createOrReplaceView(ident, info) Create a view, or replace it if it exists (CREATE OR REPLACE VIEW); the default calls replaceView and falls back to createView, which is not atomic, so override it for an atomic swap
dropView(ident) Drop a view
renameView(oldIdent, newIdent) Rename a view

Note: The built-in session catalog (V2SessionCatalog) is not a ViewCatalog; views in the session catalog continue to use the v1 (Hive-compatible) code path. DSV2 view support applies to custom catalogs registered via spark.sql.catalog.<name>.

FunctionCatalog

FunctionCatalog adds user-defined function management:

Method Description
listFunctions(namespace) List functions in a namespace
loadFunction(ident) Load an UnboundFunction by identifier

ProcedureCatalog

ProcedureCatalog adds stored-procedure support:

Method Description
listProcedures(namespace) List procedures in a namespace
loadProcedure(ident) Load an UnboundProcedure by identifier

Procedures are invoked via CALL catalog.procedure(args).

Table

Table is the central abstraction representing a logical dataset — for example, a directory of Parquet files, a Kafka topic, or a table managed by an external metastore. A Table gains read and write abilities through mix-in interfaces.

A Table provides:

Method Description
name() A human-readable identifier for the table
columns() The table’s columns (replaces the deprecated schema() method)
partitioning() Physical partitioning expressed as Transform arrays
properties() A string map of table properties
capabilities() A set of TableCapability values declaring what the table supports

Read and Write Mix-ins

A Table gains read and write abilities by implementing mix-in interfaces:

Additional mix-ins enable further capabilities:

Mix-in Capability
SupportsDelete / SupportsDeleteV2 Filter-based row delete
TruncatableTable TRUNCATE TABLE
SupportsPartitionManagement Partition DDL (ADD/DROP/RENAME PARTITION)
SupportsMetadataColumns Expose hidden metadata columns (e.g. file name, row position)

View

View (since 4.2.0) is the metadata abstraction for a view — a named SQL query managed through a ViewCatalog. Unlike a Table, a view has no data of its own: it stores a SQL query along with the context needed to resolve and expand that query consistently whenever the view is referenced.

A View provides:

Method Description
queryText() The SQL text of the view
columns() The view’s output columns
currentCatalog() The current catalog captured at creation time, used to resolve unqualified identifiers in the query text
currentNamespace() The current namespace captured at creation time, used alongside currentCatalog()
sqlConfigs() The SQL configs captured at creation time, applied when parsing and analyzing the view body
schemaMode() The view’s schema binding mode (BINDING, COMPENSATION, TYPE EVOLUTION, or EVOLUTION)
queryColumnNames() Output column names of the query, used to map the query output to the view’s declared columns
viewDependencies() The structured list of objects (source tables and functions) the view depends on
properties() A string map of view properties

When a view is read (SELECT), Spark resolves and expands queryText() using the captured catalog, namespace, and SQL configs, so the view behaves consistently regardless of the session state at query time.

Read Path

The read path follows a builder pattern that separates logical planning from physical execution:

SupportsRead.newScanBuilder(options)
  └─▸ ScanBuilder          (logical: pushdown negotiation)
        └─▸ Scan            (logical: read schema, description)
              └─▸ Batch     (physical: partitions + reader factory)
                    ├─ InputPartition[]
                    └─ PartitionReaderFactory
                         └─▸ PartitionReader   (per-task I/O)

ScanBuilder

ScanBuilder is the starting point for configuring a read. Spark calls build() to obtain the final Scan.

Before calling build(), Spark negotiates operator pushdown with the scan builder by checking for mix-in interfaces. The pushdown order is:

  1. Sample (SupportsPushDownTableSample)
  2. Filter (SupportsPushDownV2Filters / SupportsPushDownFilters)
  3. Join (SupportsPushDownJoin, since 4.1.0)
  4. Aggregate (SupportsPushDownAggregates)
  5. Variant extraction (SupportsPushDownVariantExtractions, since 4.1.0)
  6. Limit, top-N and offset (SupportsPushDownLimit, SupportsPushDownTopN, SupportsPushDownOffset)
  7. Column pruning (SupportsPushDownRequiredColumns)

Spark offers aggregate, limit, top-N and offset pushdown only when no filter is left to evaluate above the scan, so a filter returned by pushPredicates blocks them. For the operators that can be partly handled, the return value tells Spark what it must still evaluate itself:

Scan

Scan is a logical representation of a configured data source read. It provides:

Method Description
readSchema() The actual schema after any column pruning or pushdown
toBatch() Returns a Batch for batch execution
toMicroBatchStream(checkpointLocation) Returns a MicroBatchStream for streaming
toContinuousStream(checkpointLocation) Returns a ContinuousStream for continuous processing

Implementations must override the method corresponding to the TableCapability declared by their Table.

Batch

Batch is the physical representation of a batch read. It has two methods:

Method Description
planInputPartitions() Returns an array of InputPartition objects; each partition maps to one Spark task
createReaderFactory() Returns a PartitionReaderFactory that is serialized and sent to executors

InputPartition and PartitionReader

InputPartition is a serializable handle representing a data split. It may optionally declare preferredLocations() for data locality.

PartitionReaderFactory is serialized to executors and creates a PartitionReader for each input partition. The reader iterates over rows (or ColumnarBatch instances for columnar sources) and is closed after consumption. For a SupportsStorageFilters scan that Spark offered a storage filter, createReader(partition, request) or createColumnarReader(partition, request) creates the reader instead. The StorageFilterRequest carries the filter, and whether the reader appends a boolean column that is true only on the rows the filter was evaluated on and kept. When the filter throws StorageFilterNotAppliedException, the reader reads the rest of the unit without it. Scala does not check exceptions, so a reader written in Scala must catch it all the same.

Scan Mix-ins

Scan implementations can opt in to additional optimizations by implementing mix-in interfaces:

Mix-in Capability
SupportsReportPartitioning Reports how the output is partitioned, allowing Spark to avoid unnecessary shuffles (e.g. Storage Partition Join)
SupportsReportOrdering Reports the sort order of the output, allowing Spark to skip redundant sorts
SupportsReportStatistics Reports table and column-level statistics for the cost-based optimizer
SupportsRuntimeV2Filtering Accepts additional filter values at execution time, used for dynamic partition pruning and to narrow row-level DML rewrites
SupportsStorageFilters Declares the columns its readers can evaluate a runtime bloom filter on, so they can skip reading the other columns of the rows it rejects. Off by default, see spark.sql.sources.v2.storageFilterPushdown.enabled

Write Path

The write path mirrors the read path with a builder pattern that separates logical configuration from physical execution:

SupportsWrite.newWriteBuilder(info)
  └─▸ WriteBuilder           (logical: mode selection)
        └─▸ Write             (logical: description, metrics)
              └─▸ BatchWrite  (physical: commit protocol)
                    ├─ DataWriterFactory
                    │    └─▸ DataWriter      (per-task I/O)
                    ├─ commit(messages[])
                    └─ abort(messages[])

WriteBuilder

WriteBuilder is the starting point for configuring a write. Calling build() returns a logical Write object.

Write modes are configured by mixing in additional interfaces on the builder:

Mix-in Mode
SupportsTruncate Truncate the table before writing
SupportsOverwriteV2 Overwrite data matching a filter expression
SupportsDynamicOverwrite Dynamically overwrite partitions

Write

Write is a logical representation of a configured write. Similar to Scan, it bridges to the physical layer:

Method Description
toBatch() Returns a BatchWrite for batch execution
toStreaming() Returns a StreamingWrite for streaming execution

BatchWrite

BatchWrite defines the two-phase commit protocol for batch writes:

  1. createBatchWriterFactory(info) — creates a DataWriterFactory that is serialized and sent to executors.
  2. On each executor, the factory creates a DataWriter per task attempt. When the attempt has written its rows and the commit coordinator allows it, Spark calls commit(), which returns a WriterCommitMessage; if the attempt fails, Spark calls abort(). Failed tasks are retried.
  3. On the driver, onDataWriterCommit(message) runs as each task’s message arrives. If every task commits, Spark calls commit(messages[]); if the job fails after task retries, or onDataWriterCommit or commit throws, it calls abort(messages[]).

Data written by individual tasks should not be visible to readers until the driver-level commit succeeds.

Distribution and Ordering Requirements

A Write implementation can also implement RequiresDistributionAndOrdering to tell Spark how input data must be distributed and sorted before writing. Spark will insert shuffle and sort nodes as needed to satisfy these requirements.

Row-Level DML

DSV2 provides interfaces for connectors to support DELETE, UPDATE, and MERGE INTO statements.

Filter-Based Delete

The simplest form of DML is a filter-based delete, where entire groups of rows matching a predicate are removed without rewriting individual records.

SupportsDeleteV2 (and the older SupportsDelete) is a Table mix-in for this use case. Its key methods are:

Method Description
canDeleteWhere(predicates) Returns whether the data source can efficiently delete rows matching the given predicates. If this returns false, Spark falls back to row-level rewriting (Row-Level Operations) when the table also implements SupportsRowLevelOperations; otherwise the DELETE fails
deleteWhere(predicates) Deletes all rows matching the predicates

This approach is well-suited for data sources that can drop entire partitions or files without rewriting data.

Row-Level Operations

For more complex DML — such as UPDATE, MERGE INTO, or DELETE operations that cannot be handled by a simple filter — connectors implement SupportsRowLevelOperations. This interface returns a RowLevelOperation, which coordinates a read-and-rewrite cycle:

SupportsRowLevelOperations.newRowLevelOperationBuilder(info)
  └─▸ RowLevelOperation
        ├─ newScanBuilder(options)   → reads affected rows
        └─ newWriteBuilder(info)     → writes rewritten data

Data sources fall into two categories:

Expressions

The org.apache.spark.sql.connector.expressions package provides a neutral expression representation used across the DSV2 API:

These expression types are independent of Spark’s internal Catalyst expressions, allowing connectors to avoid a dependency on Spark internals.

Streaming

The same Table and Scan abstractions support streaming queries. A Table that declares MICRO_BATCH_READ or CONTINUOUS_READ capabilities provides streaming reads through:

Method Description
Scan.toMicroBatchStream(checkpointLocation) Returns a MicroBatchStream that reads data in micro-batches, tracking progress through offsets
Scan.toContinuousStream(checkpointLocation) Returns a ContinuousStream for low-latency continuous processing

A Table that declares STREAMING_WRITE supports streaming writes through Write.toStreaming(), which returns a StreamingWrite.

Building a Connector

Spark’s own test connectors make a working example of each step: JavaSimpleDataSourceV2 for a minimal read, JavaAdvancedDataSourceV2WithV2Filter for pushdown, and JavaSimpleWritableDataSource for writes, exercised by DataSourceV2Suite. The three share a few test helpers. TestingV2Source is a test trait over TableProvider: it implements inferSchema (two int columns, i and j) and forwards getTable(schema, partitioning, properties) to a one-argument getTable(options). The excerpts override the one-argument form; a connector implements the three-argument form. JavaSimpleBatchTable is a Table that declares BATCH_READ and returns that schema (through the deprecated schema(); a new Table implements columns()); JavaRangeInputPartition carries a start and end; and JavaSimpleScanBuilder is a ScanBuilder that is also its own Scan and Batch.

Step 1: A Minimal Batch Read

The smallest connector is a TableProvider that returns a Table, a Table that declares BATCH_READ and returns a ScanBuilder, and a ScanBuilder whose Batch plans partitions and creates readers. JavaSimpleDataSourceV2 is exactly that:

public class JavaSimpleDataSourceV2 implements TestingV2Source {

  static class MyScanBuilder extends JavaSimpleScanBuilder {
    @Override
    public InputPartition[] planInputPartitions() {
      InputPartition[] partitions = new InputPartition[2];
      partitions[0] = new JavaRangeInputPartition(0, 5);
      partitions[1] = new JavaRangeInputPartition(5, 10);
      return partitions;
    }
  }

  @Override
  public Table getTable(CaseInsensitiveStringMap options) {
    return new JavaSimpleBatchTable() {
      @Override
      public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) {
        return new MyScanBuilder();
      }
    };
  }
}

planInputPartitions() runs once on the driver and returns one InputPartition per task. Each partition is serialized to an executor, so it should hold only what identifies the split (here a range of integers; for a file source, a path and a byte range). The PartitionReaderFactory is serialized the same way and creates a PartitionReader on the executor. The reader is a cursor: next() advances, get() returns the current row, close() releases any handle. The one in JavaSimpleReaderFactory counts through the partition’s range:

return new PartitionReader<InternalRow>() {
  private int current = p.start - 1;

  @Override
  public boolean next() {
    current += 1;
    return current < p.end;
  }

  @Override
  public InternalRow get() {
    return new GenericInternalRow(new Object[] {current, -current});
  }

  @Override
  public void close() {
  }
};

A PartitionReader<InternalRow> produces InternalRow, which holds each column in Spark’s internal representation rather than the external one a Row carries: a boxed int is fine for an IntegerType column, but a StringType column holds UTF8String (not java.lang.String), DecimalType holds Decimal, DateType holds an int of days, TimestampType a long of microseconds, BinaryType a byte[], and nested types hold ArrayData, MapData and InternalRow. InternalRow and GenericInternalRow live in Spark’s internal catalyst package and have no API docs, but they are the row types the connector API uses. The accessor names on InternalRow (getUTF8String, getDecimal, getInt, getLong, getBinary, getArray, getMap, getStruct) are the reference for each type. GenericInternalRow takes Object[], so a value of the wrong representation compiles and fails on the executor with a ClassCastException.

With this much, spark.read.format("...JavaSimpleDataSourceV2").load() returns ten rows, and the select and filter in the “simplest implementation” test work too: Spark applies the projection and the filter itself, above the scan.

Step 2: Projection and Filter Pushdown (Optional)

A connector that can skip columns or rows adds the pushdown mix-ins to its ScanBuilder. The list, the order Spark applies them in, and the residual-filter contract are under ScanBuilder. JavaAdvancedDataSourceV2WithV2Filter accepts only i > <int literal> and returns everything else for Spark to evaluate. Unlike the test, which asserts the shapes it expects, the following version checks them, because Spark can also push i > j as a > predicate; it also matches on the public NamedReference rather than the test’s FieldReference. It accepts a threshold only from -1 up, because the planner starts the first partition at the threshold plus one and the source’s data is 0 to 9; a lower threshold stays with Spark. The planner enforces one lower bound, so the example accepts the first matching predicate and returns any others to Spark. Replacing predicates on each call is right while supportsIterativePushdown() returns false (the default); an iterative source accumulates them instead:

@Override
public Predicate[] pushPredicates(Predicate[] predicates) {
  List<Predicate> supported = new ArrayList<>();
  List<Predicate> unsupported = new ArrayList<>();
  for (Predicate p : predicates) {
    if (supported.isEmpty() && isIGreaterThanInt(p)) {
      supported.add(p);
    } else {
      unsupported.add(p);
    }
  }
  this.predicates = supported.toArray(new Predicate[0]);
  return unsupported.toArray(new Predicate[0]);
}

private static boolean isIGreaterThanInt(Predicate p) {
  return p.name().equals(">")
      && p.children()[0] instanceof NamedReference column
      && column.fieldNames().length == 1
      && column.fieldNames()[0].equals("i")
      && p.children()[1] instanceof Literal<?> value
      && value.value() instanceof Integer bound
      && bound >= -1;
}

@Override
public Predicate[] pushedPredicates() {
  return predicates;
}

@Override
public void pruneColumns(StructType requiredSchema) {
  this.requiredSchema = requiredSchema;
}

pushedPredicates() reports what the source accepted. The Batch in the same class uses the accepted predicate to plan fewer partitions, and its reader fills only the columns in requiredSchema. The “advanced implementation with V2 Filter” test in DataSourceV2Suite checks the planned Batch for a few filter and select combinations. EXPLAIN does not show pushedPredicates(); it shows Scan.description() inside the BatchScan node, after the output columns. Its default is getClass().toString(), so override it to include the accepted predicates and the pruned columns.

Step 3: Batch Writes

A writable table declares BATCH_WRITE and implements SupportsWrite. For SaveMode.Overwrite it also declares TRUNCATE (or OVERWRITE_BY_FILTER) and implements the matching builder mix-in, such as SupportsTruncate; see TableCapability for the others. Spark checks the capability set during analysis, before calling any builder, so a missing value fails the query even when the mix-in is implemented. JavaSimpleWritableDataSource declares BATCH_READ, BATCH_WRITE and TRUNCATE. A TableProvider like this one, which does not implement SupportsCatalogOptions, supports only the append and overwrite save modes; the default, errorifexists, and ignore fail with UNSUPPORTED_DATA_SOURCE_SAVE_MODE. A provider that implements SupportsCatalogOptions takes its catalog and identifier from the write options, and Spark runs those two modes as CREATE TABLE ... AS SELECT in that catalog.

The write then follows the protocol under BatchWrite. A file-based connector typically has each DataWriter write its task’s output to a staging location, and BatchWrite.commit on the driver publish the staged files. The staging location must be reachable from both sides: executors write there and the driver renames or registers from there, usually on different machines. JavaSimpleWritableDataSource stages under <path>/_temporary/<queryId> on the table’s own file system, writes one file per task (JavaCSVDataWriterFactory.createWriter), and commits by renaming the staged files into the table directory one at a time (MyBatchWrite.commit). commit is a single driver-side call, but that alone does not make the publication atomic: a reader listing the directory between two renames sees a partial result. A connector that needs all-or-nothing visibility has to provide it itself, for example by writing a manifest or committing a transaction as the last step.

Two shortcuts in the test connector are not safe outside a test. Its commit publishes every file under the staging directory rather than the ones named in messages, so a partial file left by an executor lost mid-task would be published next to the retry’s file; a real connector commits only the files its messages name. And it truncates eagerly in Write.toBatch(), before any task runs, so a failed overwrite leaves the table empty; a real connector defers the delete to BatchWrite.commit.

A WriterCommitMessage is returned by DataWriter.commit() on an executor and delivered to BatchWrite.commit on the driver, where the whole array is held in memory. It should carry a reference to what the task wrote (a path, an object key, a transaction id), never the rows. This connector keeps its state in the staging directory and returns null. BatchWrite.abort(messages) removes the staged output; it receives null entries for tasks that never committed and must tolerate them. DataWriter.abort() on the executor side deletes that task’s own file.

DataWriter.write receives rows in the internal representation described in Step 1, so a StringType column is read back with record.getUTF8String(i).toString().

Step 4: Catalog Integration (Optional)

A TableProvider is reached by class name from spark.read.format(...). To reach the same tables through SQL identifiers and DDL (CREATE TABLE demo.db.t, INSERT INTO demo.db.t, SHOW TABLES IN demo.db), implement TableCatalog, whose loadTable returns the same Table class the TableProvider builds, and register it as under CatalogPlugin:

spark.sql.catalog.demo=com.example.DemoCatalog
spark.sql.catalog.demo.warehouse=/path/to/warehouse    # arrives as options.get("warehouse")

CREATE TABLE arrives as createTable(ident, tableInfo), and tableInfo.columns() is a Column[]; Table.columns() returns the same type, while the read and write paths speak StructType. Build the array with Column.create(name, dataType, nullable). spark.sql.defaultCatalog names the catalog that is current when a session has not set one, so after SET spark.sql.defaultCatalog=demo, db.t resolves to demo.db.t.

Further Reading