Skip to content
12 changes: 9 additions & 3 deletions docs/sql-data-sources-csv.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ license: |
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---

Spark SQL provides `spark.read().csv("file_name")` to read a file or directory of files in CSV format into Spark DataFrame, and `dataframe.write().csv("path")` to write to a CSV file. Function `option()` can be used to customize the behavior of reading or writing, such as controlling behavior of the header, delimiter character, character set, and so on.
Spark SQL provides `spark.read().csv("file_name")` to read a file or directory of files in CSV format into Spark DataFrame, and `dataframe.write().csv("path")` to write to a CSV file. Function `option()` can be used to customize the behavior of reading or writing, such as controlling behavior of the header, delimiter character, character set, and so on.

<div class="codetabs">

Expand Down Expand Up @@ -162,6 +162,12 @@ Data source options of CSV can be set via:
<td>Sets the string that indicates a timestamp format. Custom date formats follow the formats at <a href="https://spark.apache.org/docs/latest/sql-ref-datetime-pattern.html">Datetime Patterns</a>. This applies to timestamp type.</td>
<td>read/write</td>
</tr>
<tr>
<td><code>timestampNTZFormat</code></td>
<td>yyyy-MM-dd'T'HH:mm:ss[.SSS]</td>
<td>Sets the string that indicates a timestamp without timezone format. Custom date formats follow the formats at <a href="https://spark.apache.org/docs/latest/sql-ref-datetime-pattern.html">Datetime Patterns</a>. This applies to timestamp without timezone type, note that zone-offset and time-zone components are not supported when writing or reading this data type.</td>
<td>read/write</td>
</tr>
<tr>
<td><code>maxColumns</code></td>
<td>20480</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import org.apache.spark.sql.catalyst.expressions.ExprUtils
import org.apache.spark.sql.catalyst.util.LegacyDateFormats.FAST_DATE_FORMAT
import org.apache.spark.sql.catalyst.util.TimestampFormatter
import org.apache.spark.sql.errors.QueryExecutionErrors
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types._

class CSVInferSchema(val options: CSVOptions) extends Serializable {
Expand All @@ -38,6 +39,13 @@ class CSVInferSchema(val options: CSVOptions) extends Serializable {
legacyFormat = FAST_DATE_FORMAT,
isParsing = true)

private val timestampNTZFormatter = TimestampFormatter(
options.timestampNTZFormatInRead,
options.zoneId,
legacyFormat = FAST_DATE_FORMAT,
isParsing = true,
forTimestampNTZ = true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this part I'd defer to @MaxGekk to review.


private val decimalParser = if (options.locale == Locale.US) {
// Special handling the default locale for backward compatibility
s: String => new java.math.BigDecimal(s)
Expand Down Expand Up @@ -109,6 +117,7 @@ class CSVInferSchema(val options: CSVOptions) extends Serializable {
case LongType => tryParseLong(field)
case _: DecimalType => tryParseDecimal(field)
case DoubleType => tryParseDouble(field)
case TimestampNTZType => tryParseTimestampNTZ(field)
case TimestampType => tryParseTimestamp(field)
case BooleanType => tryParseBoolean(field)
case StringType => StringType
Expand Down Expand Up @@ -160,6 +169,15 @@ class CSVInferSchema(val options: CSVOptions) extends Serializable {
private def tryParseDouble(field: String): DataType = {
if ((allCatch opt field.toDouble).isDefined || isInfOrNan(field)) {
DoubleType
} else {
tryParseTimestampNTZ(field)
Comment thread
sadikovi marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I supported timestamp inference in JSON #23201, I had to introduce new option in #23455, and disable the inference by default in #28966 because such inference slowed down user queries. If you are sure that is not the case, please, extend CSV benchmarks to proof the performance doesn't degrade.

@sadikovi sadikovi Nov 24, 2021

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not working on JSON just yet, this is the CSV data source.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I showed JSON as an example because I guess CSV has similar issue.

}
}

private def tryParseTimestampNTZ(field: String): DataType = {
if ((allCatch opt !timestampNTZFormatter.isTimeZoneSet(field)).getOrElse(false) &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's add one-line comment here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added, thanks.

(allCatch opt timestampNTZFormatter.parseWithoutTimeZone(field)).isDefined) {
SQLConf.get.timestampType
} else {
tryParseTimestamp(field)
}
Expand Down Expand Up @@ -225,6 +243,10 @@ class CSVInferSchema(val options: CSVOptions) extends Serializable {
} else {
Some(DecimalType(range + scale, scale))
}

case (TimestampNTZType, TimestampType) | (TimestampType, TimestampNTZType) =>
Some(TimestampType)

case _ => None
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ class CSVOptions(
s"${DateFormatter.defaultPattern}'T'HH:mm:ss[.SSS][XXX]"
})

val timestampNTZFormatInRead: Option[String] = parameters.get("timestampNTZFormat").orElse {
if (SQLConf.get.legacyTimeParserPolicy == LegacyBehaviorPolicy.LEGACY) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's ignore the legacy behavior in TimestampNTZ.
For the casting between string and TimestampNTZ, we didn't respect it either.

Some(s"${DateFormatter.defaultPattern}'T'HH:mm:ss.SSS")
} else {
None
}
}
val timestampNTZFormatInWrite: String = parameters.getOrElse("timestampNTZFormat",
if (SQLConf.get.legacyTimeParserPolicy == LegacyBehaviorPolicy.LEGACY) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

s"${DateFormatter.defaultPattern}'T'HH:mm:ss.SSS"
} else {
s"${DateFormatter.defaultPattern}'T'HH:mm:ss[.SSS]"
})

val multiLine = parameters.get("multiLine").map(_.toBoolean).getOrElse(false)

val maxColumns = getInt("maxColumns", 20480)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class UnivocityGenerator(
legacyFormat = FAST_DATE_FORMAT,
isParsing = false)
private val timestampNTZFormatter = TimestampFormatter(
options.timestampFormatInWrite,
options.timestampNTZFormatInWrite,
options.zoneId,
legacyFormat = FAST_DATE_FORMAT,
isParsing = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class UnivocityParser(
legacyFormat = FAST_DATE_FORMAT,
isParsing = true)
private lazy val timestampNTZFormatter = TimestampFormatter(
options.timestampFormatInRead,
options.timestampNTZFormatInRead,
options.zoneId,
legacyFormat = FAST_DATE_FORMAT,
isParsing = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ sealed trait TimestampFormatter extends Serializable {
s"The method `parseWithoutTimeZone(s: String)` should be implemented in the formatter " +
"of timestamp without time zone")

/**
* Returns true if the parsed timestamp contains the time zone component, false otherwise.
* Used to determine if the timestamp can be inferred as timestamp without time zone.
*
* @param s - string with timestamp to inspect
* @return whether the timestamp string has the time zone component defined.
*/
@throws(classOf[IllegalStateException])
def isTimeZoneSet(s: String): Boolean =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API looks a bit weird. How about we throw exception in parseWithoutTimeZone if the input string has timezone?

BTW is it possible to statically fail the ntz formatter creating if the pattern string contains timezone? Then we can fail earlier before processing data.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can update.

IMHO, it is tricky to check this statically as there does not seem to be a way of checking pattern components and we need an actual value to validate whether or not it has a zone offset.

throw new IllegalStateException(
s"The method `isTimeZoneSet(s: String)` should be implemented in the formatter " +
"of timestamp without time zone")

def format(us: Long): String
def format(ts: Timestamp): String
def format(instant: Instant): String
Expand Down Expand Up @@ -127,6 +140,14 @@ class Iso8601TimestampFormatter(
} catch checkParsedDiff(s, legacyFormatter.parse)
}

override def isTimeZoneSet(s: String): Boolean = {
try {
val parsed = formatter.parse(s)
val parsedZoneId = parsed.query(TemporalQueries.zone())
parsedZoneId != null
} catch checkParsedDiff(s, legacyFormatter.isTimeZoneSet)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

}

override def format(instant: Instant): String = {
try {
formatter.withZone(zoneId).format(instant)
Expand Down Expand Up @@ -191,6 +212,13 @@ class DefaultTimestampFormatter(
DateTimeUtils.stringToTimestampWithoutTimeZoneAnsi(UTF8String.fromString(s))
} catch checkParsedDiff(s, legacyFormatter.parse)
}

override def isTimeZoneSet(s: String): Boolean = {
try {
val (_, zoneIdOpt, _) = parseTimestampString(UTF8String.fromString(s))
zoneIdOpt.isDefined
} catch checkParsedDiff(s, legacyFormatter.isTimeZoneSet)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,162 @@ abstract class CSVSuite
}
}

test("SPARK-37326: Use different pattern to write and infer TIMESTAMP_NTZ values") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test title says about different patterns but the patterns are the same in write and in inferring, in fact.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate please? The code does use different timestamp format compared to the default one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test title confuses because it can be read as the patterns in write and infer are different in the test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me rewrite this.

withTempDir { dir =>
val path = s"${dir.getCanonicalPath}/csv"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just write directly to dir

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I could not because the directory already exists by the time the method is called. withTempDir creates a directory, but it needs to be non-existent for Spark to write into.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is withTempPath which doesn't create any dirs:

/**
* Generates a temporary path without creating the actual file/directory, then pass it to `f`. If
* a file/directory is created there by `f`, it will be delete after `f` returns.
*/
protected def withTempPath(f: File => Unit): Unit = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to use withTempPath

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems there is a mixture of withTempDir and withTempPath in this file arguably being used with the same purpose. I will open a PR to fix the tests I added but I am not going to update other occurrences.


val exp = spark.sql("select timestamp_ntz'2020-12-12 12:12:12' as col0")
exp.write.format("csv").option("timestampNTZFormat", "yyyy-MM-dd HH:mm:ss").save(path)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you test max precision with pattern .SSSSSS, please.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.


withSQLConf(SQLConf.TIMESTAMP_TYPE.key -> SQLConf.TimestampTypes.TIMESTAMP_NTZ.toString) {
val res = spark.read
.format("csv")
.option("inferSchema", "true")
.option("timestampNTZFormat", "yyyy-MM-dd HH:mm:ss")
.load(path)

checkAnswer(res, exp)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkAnswer doesn't check the type. Could you check that the inferred type is correct.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't that fail the answer since the values would be different? Yes, I can check the type explicitly, thanks.

}
}
}

test("SPARK-37326: Use different pattern to write and infer TIMESTAMP_LTZ values") {
withTempDir { dir =>
val path = s"${dir.getCanonicalPath}/csv"

val exp = spark.sql("select timestamp_ltz'2020-12-12 12:12:12' as col0")
exp.write.format("csv").option("timestampFormat", "yyyy-MM-dd HH:mm:ss").save(path)

withSQLConf(SQLConf.TIMESTAMP_TYPE.key -> SQLConf.TimestampTypes.TIMESTAMP_LTZ.toString) {
val res = spark.read
.format("csv")
.option("inferSchema", "true")
.option("timestampFormat", "yyyy-MM-dd HH:mm:ss")
.load(path)

checkAnswer(res, exp)
}
}
}

test("SPARK-37326: Roundtrip in reading and writing TIMESTAMP_NTZ values with custom schema") {
withTempDir { dir =>
val path = s"${dir.getCanonicalPath}/csv"

val exp = spark.sql("""
select
timestamp_ntz'2020-12-12 12:12:12' as col1,
timestamp_ltz'2020-12-12 12:12:12' as col2
""")

exp.write.format("csv").option("header", "true").save(path)

val res = spark.read
.format("csv")
.schema("col1 TIMESTAMP_NTZ, col2 TIMESTAMP_LTZ")
.option("header", "true")
.load(path)

checkAnswer(res, exp)
}
}

test("SPARK-37326: Timestamp type inference for a column with TIMESTAMP_NTZ values") {
withTempDir { dir =>
val path = s"${dir.getCanonicalPath}/csv"

val exp = spark.sql("""
select timestamp_ntz'2020-12-12 12:12:12' as col0 union all
select timestamp_ntz'2020-12-12 12:12:12' as col0
""")

exp.write.format("csv").option("header", "true").save(path)

val timestampTypes = Seq(
SQLConf.TimestampTypes.TIMESTAMP_NTZ.toString,
SQLConf.TimestampTypes.TIMESTAMP_LTZ.toString)

for (timestampType <- timestampTypes) {
withSQLConf(SQLConf.TIMESTAMP_TYPE.key -> timestampType) {
val res = spark.read
.format("csv")
.option("inferSchema", "true")
.option("header", "true")
.load(path)

if (timestampType == SQLConf.TimestampTypes.TIMESTAMP_NTZ.toString) {
checkAnswer(res, exp)
} else {
checkAnswer(
res,
spark.sql("""
select timestamp_ltz'2020-12-12 12:12:12' as col0 union all
select timestamp_ltz'2020-12-12 12:12:12' as col0
""")
)
}
}
}
}
}

test("SPARK-37326: Timestamp type inference for a mix of TIMESTAMP_NTZ and TIMESTAMP_LTZ") {
withTempDir { dir =>
val path = s"${dir.getCanonicalPath}/csv"

Seq(
"col0",
"2020-12-12T12:12:12.000",
"2020-12-12T17:12:12.000Z",
"2020-12-12T17:12:12.000+05:00",
"2020-12-12T12:12:12.000"
).toDF("data")
.coalesce(1)
.write.text(path)

val res = spark.read
.format("csv")
.option("inferSchema", "true")
Comment thread
gengliangwang marked this conversation as resolved.
Outdated
.option("header", "true")
.load(path)

if (spark.conf.get(SQLConf.LEGACY_TIME_PARSER_POLICY.key) == "legacy") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that we need a loop to set the config

// Timestamps without timezone are parsed as strings, so the col0 type would be StringType
// which is similar to reading without schema inference.
val exp = spark.read.format("csv").option("header", "true").load(path)
checkAnswer(res, exp)
} else {
val exp = spark.sql("""
select timestamp_ltz'2020-12-12T12:12:12.000' as col0 union all
select timestamp_ltz'2020-12-12T17:12:12.000Z' as col0 union all
select timestamp_ltz'2020-12-12T17:12:12.000+05:00' as col0 union all
select timestamp_ltz'2020-12-12T12:12:12.000' as col0
""")
checkAnswer(res, exp)
}
}
}

test("SPARK-37326: Fail to write TIMESTAMP_NTZ if timestampNTZFormat contains zone offset") {
val patterns = Seq(
"yyyy-MM-dd HH:mm:ss XXX",
"yyyy-MM-dd HH:mm:ss Z",
"yyyy-MM-dd HH:mm:ss z")

val exp = spark.sql("select timestamp_ntz'2020-12-12 12:12:12' as col0")
for (pattern <- patterns) {
withTempDir { dir =>
val path = s"${dir.getCanonicalPath}/csv"
val err = intercept[SparkException] {
exp.write.format("csv").option("timestampNTZFormat", pattern).save(path)
}
assert(
err.getCause.getMessage.contains("Unsupported field: OffsetSeconds") ||
err.getCause.getMessage.contains("Unable to extract value"))
}
}
}

test("Write dates correctly with dateFormat option") {
val customSchema = new StructType(Array(StructField("date", DateType, true)))
withTempDir { dir =>
Expand Down