Driver version
12.8.1.jre11 (root cause re-verified identical on 13.4.0 and main as of 2026-07 — unaffected by driver version)
SQL Server version
Microsoft SQL Server 2022 (RTM-CU25) (KB5081477) - 16.0.4255.1 (X64)
Apr 23 2026 22:38:54
Copyright (C) 2022 Microsoft Corporation
Developer Edition (64-bit) on Linux (Ubuntu 22.04.5 LTS) <X64>
Also confirmed the same behavior in production against SQL Server 2019 (on-prem) and Azure SQL.
Client Operating System
Repro run on macOS (Apple Silicon) against a Linux (Ubuntu 22.04) SQL Server container. Also occurs in our production deployment, where the client JVM itself runs on Linux (Flink task managers on apache/flink:1.17.2-jammy).
JAVA/JVM version
Repro run with:
openjdk version "17.0.19" 2026-04-21 LTS
OpenJDK Runtime Environment Corretto-17.0.19.10.1 (build 17.0.19+10-LTS)
OpenJDK 64-Bit Server VM Corretto-17.0.19.10.1 (build 17.0.19+10-LTS, mixed mode, sharing)
Production usage is on Java 11 (driver classifier mssql-jdbc-12.8.1.jre11) — behavior is identical, this isn't JVM-version-dependent.
Table schema
CREATE TABLE dbo.GuidBulkCopyRepro (
id uniqueidentifier NOT NULL
);
Same behavior reproduces on any table with a uniqueidentifier column when populated via SQLServerBulkCopy.
Problem description
SQLServerBulkCopy never sends the native uniqueidentifier TDS wire type for a bulk-inserted GUID column, even when the source explicitly declares microsoft.sql.Types.GUID via ISQLServerBulkData. The INSERT BULK column list always shows the source column as CHAR(36), so SQL Server has to run CONVERT_IMPLICIT(uniqueidentifier, ...) on every single row. At high row volumes (our case: tens of millions of rows/day through this path) this is a measurable, avoidable per-row CPU cost, and PlanAffectingConvert is also a documented cardinality-estimation red flag that can push the optimizer toward bad plans.
This looks closely related to #1999 (same root cause, different symptom — that one hard-fails on Azure Synapse instead of silently converting).
Root cause
Traced in SQLServerBulkCopy.java (line refs from 12.8.1, re-verified identical on 13.4.0 and main):
-
getDestTypeFromSrcType(...) — the case microsoft.sql.Types.GUID: branch unconditionally returns SSType.CHAR.toString() + "(" + bulkPrecision + ")". There is no branch that returns "uniqueidentifier". This string builds the INSERT BULK column list sent to the server, so the source column is always declared CHAR(36), regardless of the destination column's real type.
case microsoft.sql.Types.GUID:
// For char the value has to be between 0 to 8000.
return SSType.CHAR.toString() + "(" + bulkPrecision + ")";
-
writeTypeInfo(...) — the native TDSType.GUID token is only emitted when isBaseType && (SSType.GUID == destSSType):
case microsoft.sql.Types.GUID:
case java.sql.Types.CHAR:
if (isBaseType && (SSType.GUID == destSSType)) {
tdsWriter.writeByte(TDSType.GUID.byteValue());
tdsWriter.writeByte((byte) 0x10);
} else {
// ... falls through to NCHAR/BIGCHAR
}
-
isBaseType is only ever true for the Always Encrypted BaseTypeInfo sub-token (the plaintext-type descriptor written alongside CryptoMetaData, used purely for encrypt/decrypt bookkeeping — the actual on-wire value for an AE column is sent as VARBINARY ciphertext separately). Every ordinary (non-encrypted) bulk column goes through the other call site, which hardcodes isBaseType = false. So for a plain uniqueidentifier destination column, the native GUID token is architecturally unreachable — there's no connection property, driver version, or ISQLServerBulkData/ISQLServerBulkRecord declaration that changes this.
Also confirmed empirically: declaring the source column as java.sql.Types.BINARY(16) instead of GUID does not help — SQL Server still runs an implicit convert (binary(16) → uniqueidentifier), just a cheaper one (16-byte reinterpret instead of a 36-char string parse). The fix has to be on the wire-type/TDS-token side, not the source JDBC type declaration.
Feels closely related to #1582 → #2324 (reverted) → #2370, which added native TDSType.GUID (0x24) support for the prepared-statement / RPC path (dtv.java, SQLServerConnection.java) in 12.7.0. That work proves the driver can correctly encode a native GUID token outside the AE path — it just never got extended to SQLServerBulkCopy.
Expected behavior
INSERT BULK declares [id] UNIQUEIDENTIFIER for a uniqueidentifier destination column, with no server-side conversion.
Actual behavior
INSERT BULK declares [id] CHAR(36), and the server runs CONVERT_IMPLICIT(uniqueidentifier, ...) on every row.
Verified with a minimal repro (below): create a table with one uniqueidentifier column, start a plan_affecting_convert Extended Events session, bulk-insert a single row through SQLServerBulkCopy declaring the source column as microsoft.sql.Types.GUID, then read the XE ring buffer.
Actual captured output from the repro:
Captured plan_affecting_convert columns: [id]
REPRODUCED: server ran CONVERT_IMPLICIT(uniqueidentifier, ...) on column 'id'
And from a production INSERT BULK capture (abridged, column names changed to match this repro's schema):
INSERT BULK dbo.GuidBulkCopyRepro ([id] CHAR(36) COLLATE SQL_Latin1_General_CP1_CI_AS) with (ROWS_PER_BATCH = 1048576)
plan_affecting_convert: CONVERT_IMPLICIT(uniqueidentifier,[!BulkInsert].[id],0)
Error message/stack trace
No exception — this is a silent behavioral/performance issue, not a crash. The evidence is server-side (an Extended Events plan_affecting_convert event and the INSERT BULK column-type declaration), not a client-side stack trace. See the repro output above and the runnable test below.
Any other details that can be helpful
Minimal standalone repro (no test framework dependency — plain JDBC + ISQLServerBulkData). Set MSSQL_JDBC_TEST_CONNECTION_PROPERTIES (same env var name this repo's own AbstractTest uses) or pass the connection string as args[0], then run.
GuidBulkCopyImplicitConvertRepro.java
import com.microsoft.sqlserver.jdbc.ISQLServerBulkData;
import com.microsoft.sqlserver.jdbc.SQLServerBulkCopy;
import com.microsoft.sqlserver.jdbc.SQLServerBulkCopyOptions;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
public class GuidBulkCopyImplicitConvertRepro {
private static final String TABLE = "dbo.GuidBulkCopyRepro";
private static final String XE_SESSION = "GuidBulkCopyReproXE";
public static void main(String[] args) throws Exception {
String connString = args.length > 0 ? args[0] : System.getenv("MSSQL_JDBC_TEST_CONNECTION_PROPERTIES");
if (connString == null) {
System.err.println("Pass a JDBC connection string as arg[0] or set MSSQL_JDBC_TEST_CONNECTION_PROPERTIES");
System.exit(1);
}
try (Connection connection = DriverManager.getConnection(connString)) {
setUp(connection);
try {
runRepro(connection);
} finally {
tearDown(connection);
}
}
}
private static void setUp(Connection connection) throws Exception {
try (Statement st = connection.createStatement()) {
st.execute("IF OBJECT_ID('" + TABLE + "') IS NOT NULL DROP TABLE " + TABLE);
st.execute("CREATE TABLE " + TABLE + " (id uniqueidentifier NOT NULL)");
st.execute("IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = '"
+ XE_SESSION + "') DROP EVENT SESSION " + XE_SESSION + " ON SERVER");
st.execute(
"CREATE EVENT SESSION " + XE_SESSION + " ON SERVER " +
"ADD EVENT sqlserver.plan_affecting_convert( " +
" WHERE sqlserver.database_name = N'" + connection.getCatalog() + "') " +
"ADD TARGET package0.ring_buffer " +
"WITH (MAX_DISPATCH_LATENCY = 1 SECONDS)");
st.execute("ALTER EVENT SESSION " + XE_SESSION + " ON SERVER STATE = START");
st.execute("DBCC FREEPROCCACHE");
}
}
private static void tearDown(Connection connection) throws Exception {
try (Statement st = connection.createStatement()) {
st.execute("IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = '"
+ XE_SESSION + "') DROP EVENT SESSION " + XE_SESSION + " ON SERVER");
st.execute("IF OBJECT_ID('" + TABLE + "') IS NOT NULL DROP TABLE " + TABLE);
}
}
private static void runRepro(Connection connection) throws Exception {
try (SQLServerBulkCopy bulkCopy = new SQLServerBulkCopy(connection)) {
bulkCopy.setBulkCopyOptions(new SQLServerBulkCopyOptions());
bulkCopy.setDestinationTableName(TABLE);
bulkCopy.writeToServer(new SingleGuidColumnRecord(UUID.randomUUID()));
}
Thread.sleep(1500);
Set<String> convertedColumns = readConvertedColumnsFromRingBuffer(connection);
System.out.println("Captured plan_affecting_convert columns: " + convertedColumns);
if (convertedColumns.contains("id")) {
System.out.println("REPRODUCED: server ran CONVERT_IMPLICIT(uniqueidentifier, ...) on column 'id'");
} else {
System.out.println("NOT REPRODUCED: no convert captured on column 'id' (bug fixed, or XE session missed the event)");
}
}
private static final class SingleGuidColumnRecord implements ISQLServerBulkData {
private final UUID value;
private boolean consumed = false;
SingleGuidColumnRecord(UUID value) {
this.value = value;
}
@Override
public Set<Integer> getColumnOrdinals() {
return Set.of(1);
}
@Override
public String getColumnName(int column) {
return "id";
}
@Override
public int getColumnType(int column) {
return microsoft.sql.Types.GUID;
}
@Override
public int getPrecision(int column) {
return 36;
}
@Override
public int getScale(int column) {
return 0;
}
@Override
public Object[] getRowData() {
return new Object[] { value.toString() };
}
@Override
public boolean next() {
if (consumed) {
return false;
}
consumed = true;
return true;
}
}
private static Set<String> readConvertedColumnsFromRingBuffer(Connection connection) throws Exception {
String xml;
try (Statement st = connection.createStatement();
ResultSet rs = st.executeQuery(
"SELECT CAST(t.target_data AS NVARCHAR(MAX)) AS target_data " +
"FROM sys.dm_xe_session_targets t " +
"JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address " +
"WHERE s.name = '" + XE_SESSION + "'")) {
rs.next();
xml = rs.getString("target_data");
}
Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
// Each <event name="plan_affecting_convert"> carries an "expression" data field like:
// CONVERT_IMPLICIT(uniqueidentifier,[!BulkInsert].[id],0)
NodeList dataNodes = doc.getElementsByTagName("data");
return java.util.stream.IntStream.range(0, dataNodes.getLength())
.mapToObj(i -> (Element) dataNodes.item(i))
.filter(el -> "expression".equals(el.getAttribute("name")))
.map(el -> el.getElementsByTagName("value").item(0).getTextContent())
.filter(expr -> expr.contains("CONVERT_IMPLICIT(uniqueidentifier"))
.flatMap(expr -> java.util.regex.Pattern.compile("\\.\\[(\\w+)\\]\\s*,0\\)")
.matcher(expr).results().map(m -> m.group(1)))
.collect(Collectors.toSet());
}
}
Suggested fix
In SQLServerBulkCopy.java:
getDestTypeFromSrcType: when destSSType == SSType.GUID, return "uniqueidentifier" instead of CHAR(n).
writeTypeInfo: drop the isBaseType && gate for the GUID case — emit TDSType.GUID whenever destSSType == SSType.GUID, regardless of AE.
- Value-writing path (
writeColumnToTdsWriter): for that branch, write the 16-byte GUID layout (the driver already has this — Util.asGuidByteArray(UUID.fromString(value)), currently only invoked from the AE-related value-conversion path) instead of the CHAR string bytes, and handle NULL as a single 0x00 length byte (matching the GUID-null convention used elsewhere in this file) rather than the CHAR 0xFF 0xFF NULL marker.
Happy to help test against a candidate fix — this is currently forcing us to consider a class-shadowing workaround on our end, which we'd much rather not maintain long-term.
JDBC trace logs
Not applicable here in the usual sense — the client-side JDBC trace log doesn't surface this, since the implicit conversion happens server-side after the INSERT BULK statement is sent; it isn't a client-observable error or warning. The definitive evidence is the server-side Extended Events capture shown above (plan_affecting_convert / CONVERT_IMPLICIT), reproduced by the attached repro. Happy to also provide the driver's own TDS-level trace output (enabled per the driver tracing docs) showing the outgoing CHAR(36) column-type declaration if that's useful in addition.
Driver version
12.8.1.jre11 (root cause re-verified identical on 13.4.0 and
mainas of 2026-07 — unaffected by driver version)SQL Server version
Also confirmed the same behavior in production against SQL Server 2019 (on-prem) and Azure SQL.
Client Operating System
Repro run on macOS (Apple Silicon) against a Linux (Ubuntu 22.04) SQL Server container. Also occurs in our production deployment, where the client JVM itself runs on Linux (Flink task managers on
apache/flink:1.17.2-jammy).JAVA/JVM version
Repro run with:
Production usage is on Java 11 (driver classifier
mssql-jdbc-12.8.1.jre11) — behavior is identical, this isn't JVM-version-dependent.Table schema
Same behavior reproduces on any table with a
uniqueidentifiercolumn when populated viaSQLServerBulkCopy.Problem description
SQLServerBulkCopynever sends the nativeuniqueidentifierTDS wire type for a bulk-inserted GUID column, even when the source explicitly declaresmicrosoft.sql.Types.GUIDviaISQLServerBulkData. TheINSERT BULKcolumn list always shows the source column asCHAR(36), so SQL Server has to runCONVERT_IMPLICIT(uniqueidentifier, ...)on every single row. At high row volumes (our case: tens of millions of rows/day through this path) this is a measurable, avoidable per-row CPU cost, andPlanAffectingConvertis also a documented cardinality-estimation red flag that can push the optimizer toward bad plans.This looks closely related to #1999 (same root cause, different symptom — that one hard-fails on Azure Synapse instead of silently converting).
Root cause
Traced in
SQLServerBulkCopy.java(line refs from 12.8.1, re-verified identical on 13.4.0 andmain):getDestTypeFromSrcType(...)— thecase microsoft.sql.Types.GUID:branch unconditionally returnsSSType.CHAR.toString() + "(" + bulkPrecision + ")". There is no branch that returns"uniqueidentifier". This string builds theINSERT BULKcolumn list sent to the server, so the source column is always declaredCHAR(36), regardless of the destination column's real type.writeTypeInfo(...)— the nativeTDSType.GUIDtoken is only emitted whenisBaseType && (SSType.GUID == destSSType):isBaseTypeis only evertruefor the Always EncryptedBaseTypeInfosub-token (the plaintext-type descriptor written alongsideCryptoMetaData, used purely for encrypt/decrypt bookkeeping — the actual on-wire value for an AE column is sent as VARBINARY ciphertext separately). Every ordinary (non-encrypted) bulk column goes through the other call site, which hardcodesisBaseType = false. So for a plainuniqueidentifierdestination column, the native GUID token is architecturally unreachable — there's no connection property, driver version, orISQLServerBulkData/ISQLServerBulkRecorddeclaration that changes this.Also confirmed empirically: declaring the source column as
java.sql.Types.BINARY(16)instead ofGUIDdoes not help — SQL Server still runs an implicit convert (binary(16)→uniqueidentifier), just a cheaper one (16-byte reinterpret instead of a 36-char string parse). The fix has to be on the wire-type/TDS-token side, not the source JDBC type declaration.Feels closely related to #1582 → #2324 (reverted) → #2370, which added native
TDSType.GUID (0x24)support for the prepared-statement / RPC path (dtv.java,SQLServerConnection.java) in 12.7.0. That work proves the driver can correctly encode a native GUID token outside the AE path — it just never got extended toSQLServerBulkCopy.Expected behavior
INSERT BULKdeclares[id] UNIQUEIDENTIFIERfor auniqueidentifierdestination column, with no server-side conversion.Actual behavior
INSERT BULKdeclares[id] CHAR(36), and the server runsCONVERT_IMPLICIT(uniqueidentifier, ...)on every row.Verified with a minimal repro (below): create a table with one
uniqueidentifiercolumn, start aplan_affecting_convertExtended Events session, bulk-insert a single row throughSQLServerBulkCopydeclaring the source column asmicrosoft.sql.Types.GUID, then read the XE ring buffer.Actual captured output from the repro:
And from a production
INSERT BULKcapture (abridged, column names changed to match this repro's schema):Error message/stack trace
No exception — this is a silent behavioral/performance issue, not a crash. The evidence is server-side (an Extended Events
plan_affecting_convertevent and theINSERT BULKcolumn-type declaration), not a client-side stack trace. See the repro output above and the runnable test below.Any other details that can be helpful
Minimal standalone repro (no test framework dependency — plain JDBC +
ISQLServerBulkData). SetMSSQL_JDBC_TEST_CONNECTION_PROPERTIES(same env var name this repo's ownAbstractTestuses) or pass the connection string asargs[0], then run.GuidBulkCopyImplicitConvertRepro.java
Suggested fix
In
SQLServerBulkCopy.java:getDestTypeFromSrcType: whendestSSType == SSType.GUID, return"uniqueidentifier"instead ofCHAR(n).writeTypeInfo: drop theisBaseType &&gate for the GUID case — emitTDSType.GUIDwheneverdestSSType == SSType.GUID, regardless of AE.writeColumnToTdsWriter): for that branch, write the 16-byte GUID layout (the driver already has this —Util.asGuidByteArray(UUID.fromString(value)), currently only invoked from the AE-related value-conversion path) instead of the CHAR string bytes, and handle NULL as a single0x00length byte (matching the GUID-null convention used elsewhere in this file) rather than the CHAR0xFF 0xFFNULL marker.Happy to help test against a candidate fix — this is currently forcing us to consider a class-shadowing workaround on our end, which we'd much rather not maintain long-term.
JDBC trace logs
Not applicable here in the usual sense — the client-side JDBC trace log doesn't surface this, since the implicit conversion happens server-side after the
INSERT BULKstatement is sent; it isn't a client-observable error or warning. The definitive evidence is the server-side Extended Events capture shown above (plan_affecting_convert/CONVERT_IMPLICIT), reproduced by the attached repro. Happy to also provide the driver's own TDS-level trace output (enabled per the driver tracing docs) showing the outgoingCHAR(36)column-type declaration if that's useful in addition.