This is an automated email from the ASF dual-hosted git repository.
HyukjinKwon pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new ad80f662b82e [SPARK-57572][SQL] Infer TimeType during CSV and JSON
schema inference
ad80f662b82e is described below
commit ad80f662b82eee42a462ec34a50c4180ff322d16
Author: Shrirang Mhalgi <[email protected]>
AuthorDate: Tue Jun 23 07:29:15 2026 +0900
[SPARK-57572][SQL] Infer TimeType during CSV and JSON schema inference
### What changes were proposed in this pull request?
Add `TimeType` inference to `CSVInferSchema` and `JsonInferSchema`,
following the existing `DateType/TimestampType` inference pattern. A
`tryParseTime` step is inserted in the CSV type ladder between `DoubleType` and
`DateType`, and an equivalent check is added to the JSON string inference path.
Both are gated behind `spark.sql.timeType.enabled` (default false).
### Why are the changes needed?
`CSVInferSchema` and `JsonInferSchema` infer `DateType` /
`TimestampNTZType` / `TimestampType` but never `TimeType`. With an explicit
schema, TIME read/write already works; only auto-inference is missing.
Time-only columns (e.g., `12:13:14`) currently infer as `StringType` even when
the TIME type feature is enabled.
### Does this PR introduce _any_ user-facing change?
Yes. When `spark.sql.timeType.enabled` is set to `true`, `CSV` and `JSON`
schema inference will recognize time-only string values (e.g., `12:13:14`,
`23:59:59.123456`) as `TimeType` instead of `StringType`. When disabled
(default), behavior is unchanged.
### How was this patch tested?
New tests in CSVInferSchemaSuite and JsonInferSchemaSuite:
- Time strings infer as `TimeType` when `spark.sql.timeType.enabled = true`
- Time strings do NOT infer as `TimeType` when disabled (existing behavior
preserved)
- `Date` / `timestamp` strings are not misclassified as time
- All existing + new tests pass for both CSV and JSON
### Was this patch authored or co-authored using generative AI tooling?
Co-Authored using Claude Opus 4.6
Closes #56634 from shrirangmhalgi/SPARK-57572-time-type-csv-json-inference.
Authored-by: Shrirang Mhalgi <[email protected]>
Signed-off-by: Hyukjin Kwon <[email protected]>
---
.../spark/sql/catalyst/csv/CSVInferSchema.scala | 19 +++++++-
.../spark/sql/catalyst/json/JsonInferSchema.scala | 14 ++++--
.../sql/catalyst/csv/CSVInferSchemaSuite.scala | 51 ++++++++++++++++++++++
.../sql/catalyst/json/JsonInferSchemaSuite.scala | 43 ++++++++++++++++++
.../sql-tests/results/csv-functions.sql.out | 4 +-
.../sql/execution/datasources/csv/CSVSuite.scala | 20 ++++++++-
.../sql/execution/datasources/json/JsonSuite.scala | 15 +++++++
7 files changed, 157 insertions(+), 9 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/CSVInferSchema.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/CSVInferSchema.scala
index 0cc11ce6bb89..48871764aa45 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/CSVInferSchema.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/CSVInferSchema.scala
@@ -25,7 +25,7 @@ import org.apache.spark.SparkException
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.catalyst.analysis.TypeCoercion
import org.apache.spark.sql.catalyst.expressions.ExprUtils
-import org.apache.spark.sql.catalyst.util.{DateFormatter, TimestampFormatter}
+import org.apache.spark.sql.catalyst.util.{DateFormatter, TimeFormatter,
TimestampFormatter}
import org.apache.spark.sql.catalyst.util.LegacyDateFormats.FAST_DATE_FORMAT
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types._
@@ -52,6 +52,12 @@ class CSVInferSchema(val options: CSVOptions) extends
Serializable {
legacyFormat = FAST_DATE_FORMAT,
isParsing = true)
+ private lazy val timeFormatter = TimeFormatter(
+ options.timeFormatInRead,
+ isParsing = true)
+
+ private val isTimeTypeEnabled = SQLConf.get.isTimeTypeEnabled
+
private val decimalParser = if (options.locale == Locale.US) {
// Special handling the default locale for backward compatibility
s: String => new java.math.BigDecimal(s)
@@ -140,6 +146,7 @@ class CSVInferSchema(val options: CSVOptions) extends
Serializable {
case LongType => tryParseLong(field)
case _: DecimalType => tryParseDecimal(field)
case DoubleType => tryParseDouble(field)
+ case _: TimeType => tryParseTime(field)
case DateType => tryParseDate(field)
case TimestampNTZType => tryParseTimestampNTZ(field)
case TimestampType => tryParseTimestamp(field)
@@ -194,12 +201,20 @@ class CSVInferSchema(val options: CSVOptions) extends
Serializable {
if ((allCatch opt field.toDouble).isDefined || isInfOrNan(field)) {
DoubleType
} else if (options.preferDate) {
- tryParseDate(field)
+ tryParseTime(field)
} else {
tryParseTimestampNTZ(field)
}
}
+ private def tryParseTime(field: String): DataType = {
+ if (isTimeTypeEnabled && (allCatch opt
timeFormatter.parse(field)).isDefined) {
+ TimeType(TimeType.DEFAULT_PRECISION)
+ } else {
+ tryParseDate(field)
+ }
+ }
+
private def tryParseDate(field: String): DataType = {
if ((allCatch opt dateFormatter.parse(field)).isDefined) {
DateType
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JsonInferSchema.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JsonInferSchema.scala
index 5587bb3d30f0..9614747ffa70 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JsonInferSchema.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/json/JsonInferSchema.scala
@@ -60,6 +60,8 @@ class JsonInferSchema(private val options: JSONOptions)
extends Serializable wit
private val ignoreMissingFiles = options.ignoreMissingFiles
private val isDefaultNTZ = SQLConf.get.timestampType == TimestampNTZType
private val legacyMode = SQLConf.get.legacyTimeParserPolicy ==
LegacyBehaviorPolicy.LEGACY
+ private val isTimeTypeEnabled = SQLConf.get.isTimeTypeEnabled
+ private lazy val timeFormatter = TimeFormatter(options.timeFormatInRead,
isParsing = true)
override def equals(obj: Any): Boolean = obj match {
case other: JsonInferSchema => options == other.options
@@ -177,10 +179,14 @@ class JsonInferSchema(private val options: JSONOptions)
extends Serializable wit
if (options.prefersDecimal && decimalTry.isDefined) {
decimalTry.get
} else if (options.inferTimestamp) {
- // For text-based format, it's ambiguous to infer a timestamp string
without timezone, as
- // it can be both TIMESTAMP LTZ and NTZ. To avoid behavior changes
with the new support
- // of NTZ, here we only try to infer NTZ if the config is set to use
NTZ by default.
- if (isDefaultNTZ &&
+ if (isTimeTypeEnabled &&
+ (allCatch opt timeFormatter.parse(field)).isDefined) {
+ TimeType(TimeType.DEFAULT_PRECISION)
+ // For text-based format, it's ambiguous to infer a timestamp string
without
+ // timezone, as it can be both TIMESTAMP LTZ and NTZ. To avoid
behavior changes
+ // with the new support of NTZ, here we only try to infer NTZ if the
config is
+ // set to use NTZ by default.
+ } else if (isDefaultNTZ &&
timestampNTZFormatter.parseWithoutTimeZoneOptional(field,
false).isDefined) {
TimestampNTZType
} else if (timestampFormatter.parseOptional(field).isDefined) {
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/csv/CSVInferSchemaSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/csv/CSVInferSchemaSuite.scala
index fb91200557a6..871bcb1ca718 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/csv/CSVInferSchemaSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/csv/CSVInferSchemaSuite.scala
@@ -273,4 +273,55 @@ class CSVInferSchemaSuite extends SparkFunSuite with
SQLHelper {
val inferSchema = new CSVInferSchema(options)
assert(inferSchema.inferField(NullType, "2884-06-24T02:45:51.138") ==
StringType)
}
+
+ test("SPARK-57572: infer TimeType when timeType.enabled is true") {
+ withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+ val options = new CSVOptions(Map.empty[String, String],
+ columnPruning = false, defaultTimeZoneId = "UTC")
+ val inferSchema = new CSVInferSchema(options)
+
+ // Basic time inference
+ assert(inferSchema.inferField(NullType, "12:13:14") ==
TimeType(TimeType.DEFAULT_PRECISION))
+ assert(inferSchema.inferField(NullType, "23:59:59") ==
TimeType(TimeType.DEFAULT_PRECISION))
+ assert(inferSchema.inferField(NullType, "00:00:00") ==
TimeType(TimeType.DEFAULT_PRECISION))
+
+ // Time with fractional seconds
+ assert(inferSchema.inferField(NullType, "12:13:14.123") ==
+ TimeType(TimeType.DEFAULT_PRECISION))
+
+ // Not a time -- should fall through to other types
+ assert(inferSchema.inferField(NullType, "not-a-time") == StringType)
+
+ // Date should NOT be inferred as time
+ assert(inferSchema.inferField(NullType, "2023-01-01") == DateType)
+ }
+ }
+
+ test("SPARK-57572: TimeType not inferred when timeType.enabled is false") {
+ withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "false") {
+ val options = new CSVOptions(Map.empty[String, String],
+ columnPruning = false, defaultTimeZoneId = "UTC")
+ val inferSchema = new CSVInferSchema(options)
+
+ // When disabled, a time-only string is not inferred as TimeType; the
lenient timestamp
+ // parser accepts it (using the current date), so it infers as
TimestampType.
+ assert(inferSchema.inferField(NullType, "12:13:14") == TimestampType)
+ }
+ }
+
+ test("SPARK-57572: TimeType cross-row merge via compatibleType") {
+ withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+ val options = new CSVOptions(Map.empty[String, String],
+ columnPruning = false, defaultTimeZoneId = "UTC")
+ val inferSchema = new CSVInferSchema(options)
+
+ val timeType = TimeType(TimeType.DEFAULT_PRECISION)
+ // Two time values merge to TimeType
+ assert(inferSchema.inferField(timeType, "23:59:59") == timeType)
+ // Time + non-time merges to StringType
+ assert(inferSchema.inferField(timeType, "not-a-time") == StringType)
+ // Time + Date merges to StringType
+ assert(inferSchema.inferField(timeType, "2023-01-01") == StringType)
+ }
+ }
}
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/json/JsonInferSchemaSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/json/JsonInferSchemaSuite.scala
index 81a4858dce82..f6fa4515f07a 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/json/JsonInferSchemaSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/json/JsonInferSchemaSuite.scala
@@ -120,4 +120,47 @@ class JsonInferSchemaSuite extends SparkFunSuite with
SQLHelper {
"""{"a": "2884-06-24T02:45:51.138"}""",
StringType)
}
+
+ test("SPARK-57572: infer TimeType when timeType.enabled is true") {
+ withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+ checkType(Map("inferTimestamp" -> "true"), """{"a": "12:13:14"}""",
+ TimeType(TimeType.DEFAULT_PRECISION))
+ checkType(Map("inferTimestamp" -> "true"), """{"a":
"23:59:59.123456"}""",
+ TimeType(TimeType.DEFAULT_PRECISION))
+ // Negative: date and timestamp strings should NOT infer as TimeType
+ checkType(Map("inferTimestamp" -> "true"), """{"a": "2023-01-01"}""",
TimestampType)
+ checkType(Map("inferTimestamp" -> "true"),
+ """{"a": "2024-06-24T02:45:51.138"}""", TimestampType)
+ }
+ }
+
+ test("SPARK-57572: TimeType not inferred when timeType.enabled is false") {
+ withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "false") {
+ // With inferTimestamp, time-only strings fall through to timestamp
(lenient parser)
+ checkType(Map("inferTimestamp" -> "true"), """{"a": "12:13:14"}""",
TimestampType)
+ // Without inferTimestamp, time-only strings become StringType
+ checkType(Map.empty[String, String], """{"a": "12:13:14"}""", StringType)
+ }
+ }
+
+ test("SPARK-57572: TimeType not inferred without inferTimestamp") {
+ withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+ // inferTimestamp defaults to false; time inference requires it
+ checkType(Map.empty[String, String], """{"a": "12:13:14"}""", StringType)
+ }
+ }
+
+ test("SPARK-57572: TimeType cross-row merge via compatibleType") {
+ withSQLConf(SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+ val timeType = TimeType(TimeType.DEFAULT_PRECISION)
+ // Two time values merge to TimeType
+ assert(JsonInferSchema.compatibleType(timeType, timeType) === timeType)
+ // Time + StringType merges to StringType
+ assert(JsonInferSchema.compatibleType(timeType, StringType) ===
StringType)
+ // Time + DateType merges to StringType (via findWiderDateTimeType ->
None)
+ assert(JsonInferSchema.compatibleType(timeType, DateType) === StringType)
+ // Time + NullType merges to TimeType
+ assert(JsonInferSchema.compatibleType(timeType, NullType) === timeType)
+ }
+ }
}
diff --git
a/sql/core/src/test/resources/sql-tests/results/csv-functions.sql.out
b/sql/core/src/test/resources/sql-tests/results/csv-functions.sql.out
index 33052dd7e8a1..11637861ccee 100644
--- a/sql/core/src/test/resources/sql-tests/results/csv-functions.sql.out
+++ b/sql/core/src/test/resources/sql-tests/results/csv-functions.sql.out
@@ -461,7 +461,7 @@ select schema_of_csv('14:30:45')
-- !query schema
struct<schema_of_csv(14:30:45):string>
-- !query output
-STRUCT<_c0: TIMESTAMP>
+STRUCT<_c0: TIME(6)>
-- !query
@@ -469,4 +469,4 @@ select schema_of_csv('14:30:45.123456')
-- !query schema
struct<schema_of_csv(14:30:45.123456):string>
-- !query output
-STRUCT<_c0: TIMESTAMP>
+STRUCT<_c0: TIME(6)>
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/csv/CSVSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/csv/CSVSuite.scala
index e48b453309aa..0144c652b688 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/csv/CSVSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/csv/CSVSuite.scala
@@ -3921,6 +3921,22 @@ abstract class CSVSuite
)
}
}
+
+ test("SPARK-57572: infer TimeType from CSV via spark.read.csv") {
+ withSQLConf(
+ SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+ withTempDir { dir =>
+ val path = s"${dir.getCanonicalPath}/time_infer.csv"
+ Seq("time", "12:13:14", "23:59:59.123456").toDF("value")
+ .coalesce(1).write.text(path)
+ val df = spark.read
+ .option("header", "true")
+ .option("inferSchema", "true")
+ .csv(path)
+ assert(df.schema("time").dataType ===
TimeType(TimeType.DEFAULT_PRECISION))
+ }
+ }
+ }
}
class CSVv1Suite extends CSVSuite {
@@ -4032,7 +4048,9 @@ class CSVLegacyTimeParserSuite extends CSVSuite {
Seq("Write timestamps correctly in ISO8601 format by default",
// The result is different because the date/timestamp parser behavior is
different. Not too
// much value to test it.
- "csv with variant")
+ "csv with variant",
+ // Legacy time parser does not support TIME type inference
+ "SPARK-57572: infer TimeType from CSV via spark.read.csv")
override protected def sparkConf: SparkConf =
super
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/JsonSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/JsonSuite.scala
index a447e9900130..8cdae049723a 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/JsonSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/json/JsonSuite.scala
@@ -4236,6 +4236,21 @@ abstract class JsonSuite
}
}
}
+
+ test("SPARK-57572: infer TimeType from JSON via spark.read.json") {
+ withSQLConf(
+ SQLConf.TIME_TYPE_ENABLED.key -> "true") {
+ withTempDir { dir =>
+ val path = s"${dir.getCanonicalPath}/time_infer.json"
+ Seq("""{"time": "12:13:14"}""", """{"time": "23:59:59.123456"}""")
+ .toDF("value").coalesce(1).write.text(path)
+ val df = spark.read
+ .option("inferTimestamp", "true")
+ .json(path)
+ assert(df.schema("time").dataType ===
TimeType(TimeType.DEFAULT_PRECISION))
+ }
+ }
+ }
}
class JsonV1Suite extends JsonSuite {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]