1

I'm currently having difficulties to convert a java.sql.Timestamp to a LocalDateTime. I understand that Timestamp (and Date) do not know any time zones. I have a Timestamp representing an UTC time. I want to convert this Timestamp to my local time zone (GMT+2). As soon as I call #toInstant() on the Timestamp I'm getting a datetime with 2 hours less. I think this is because Instant represents a point in time on UTC time scale. Apparently java thinks that my Timestamp is in my default time zone (= GMT+2), this is why toInstant() substracts two hours from the original timestamp. But this Timestamp already represents UTC time so substracting two hours from it is false.

I tried the following:

Timestamp stamp = new Timestamp(123, 7, 1, 10, 0, 0, 0); //this represents 2023-08-01 10:00:00 (UTC)
stamp.toInstant() //returns 2023-08-01 08:00:00

What I want is: 2023-08-01 12:00:00 I was able to get the result with:

Instant nowUtc = Instant.now();
ZoneId europeZone = ZoneId.of("Europe/Berlin");
ZonedDateTime berlinTime = ZonedDateTime.ofInstant(nowUtc, europeZone);

But in this working solution there is no timestamp involved.

Edit after first two answers:

  1. In the production code where I'm facing the problem the Timestamp is not constructed via the deprecated constructor new Timestamp(123, 7, 1, 10, 0, 0, 0) but it is retrieved from database via a spring data CrudRepository in conjunction with a jakarta.persitence @Entity. This is why I'm not quite sure how the Timestamp was constructed in the end, but it is also a question I don't want to ask myself.
  2. As described in the previous sentence I'm working with an existing code base where a refactoring of the persistence layer currently is not a feasible solution. That's why I have to stick with Timestamp even if I know and understand that avoiding the old date API and instead use the new java.time API is the preferred solution.
  3. My colleague finally figured out a solution for my specific problem:
@Test
    fun `convert LocalDateTime to Timestamp and back`() {
        val timestamp = Timestamp.valueOf(LocalDateTime.now(ZoneOffset.UTC))
        println("BEFORE $timestamp")
        val localDateTime = ZonedDateTime.of(timestamp.toLocalDateTime(), ZoneOffset.UTC)
            .withZoneSameInstant(ZoneId.of("Europe/Berlin")).toLocalDateTime()
        println("AFTER $localDateTime")
    }

This prints (depending on time of execution): BEFORE 2023-08-22 14:53:34.085801 AFTER 2023-08-22T16:53:34.085801

MKorsch
  • 3,822
  • 3
  • 16
  • 21
  • 2
    I *suspect* the problem is that the (deprecated) Timestamp constructor you're using actually assumes a local time rather than UTC. The documentation provides no information on this, but it seems to be consistent with both what you're seeing and what I've seen in testing. Try using the non-deprecated constructor passing in a milliseconds-since-unix-epoch value instead... – Jon Skeet Aug 22 '23 at 11:36
  • @JonSkeet You are probably right. The constructor in question calls the super (also deprecated) Date constructor, which assumes the local time zone. [Docs for constructor](https://docs.oracle.com/javase/8/docs/api/java/util/Date.html#Date-int-int-int-int-int-int-). – Chaosfire Aug 22 '23 at 11:42
  • 3
    Why do you want to use error-prone and outdated legacy date-time API when you have modern `java.time` API? You can check [this answer](https://stackoverflow.com/a/67752047/10819573) and [this answer](https://stackoverflow.com/a/67505173/10819573) to learn how to use `java.time` API with JDBC. – Arvind Kumar Avinash Aug 22 '23 at 13:11
  • You re contradicting yourself when you say *... `Timestamp` (and `Date`) do not know any time zones. I have a Timestamp representing an UTC time. ...* And this is wrong: `//this represents 2023-08-01 10:00:00 (UTC)`. No, it represents 10:00 in your default time zone. So your conversion to `Instant` is correct. – Ole V.V. Aug 22 '23 at 19:29
  • Where were you getting a `Timestamp` object from? I hope you’re not really using that 7-arg `Timestamp` constructor. It’s been deprecated for more than 25 years exactly because it works unreliably across time zones. Can you avoid getting a `Timestamp` object in the first place? If retrieving from a database, retrieve a modern `LocalDateTime` or `OffsetDateTime`instead. – Ole V.V. Aug 22 '23 at 19:37
  • Did you by any chance try `new Timestamp(123, 2, 26, 2, 30, 0, 0)`? That time did not exist in German time zone because of *spring forward*, transition to summer time. So you get 3:30 instead of 2:30. Your constructor call cannot be made to work. – Ole V.V. Aug 23 '23 at 05:36

2 Answers2

3

Context

java.sql.Timestamp extends java.util.Date. The latter is a lie - it does not represent dates at all. It represents time since the epoch without a timezone, and in that sense, the direct equivalent in the java.time package is in fact java.time.Instant, and it's best to think of them that way.

Given that the java.util time types (and therefore, all the java.sql time types too, given that they all extend j.u.Date!) are essentially broken due to being confused about their nature (such as Date being named, well, Date), you shouldn't use these types. Anywhere. Ever.

Do it right

Fortunately, you don't have to use the broken types. There is no need to mess with java.sql.Timestamp. Ever. The JDBC5 spec requires that JDBC drivers support the LocalDate, LocalTime, LocalDateTime, and OffsetDateTime modern java.time types for, respectively, SQL types DATE, TIME WITHOUT TIMEZONE, TIMESTAMP WITHOUT TIMEZONE and TIMESTAMP WITH TIMEZONE. Thus, let's say you have a table named person with a column named birthdate of type DATE, you might currently have this kind of code:

try (
  var preparedStatement = con.prepareStatement("SELECT birthdate FROM person";
  var query = preparedStatement.executeQuery()) {

  while (query.next()) {
    java.sql.Date d = query.getDate(1);  
    doStuffWith(d);
  }
}

That code is obsolete and dangerous. Instead, replace the offending line (that calls .getDate) with:

java.time.LocalDate d = query.getObject(1, LocalDate.class):

The javadoc of getObject states that whether the type you pass 'works' depends on the JDBC driver, but as mentioned, a JDBC5 compatible driver is required to support it.

You can use .setObject() (when filling values for PreparedStatements, or INSERTing data) too, passing a LocalDate instance.

This avoids all issues.

A direct answer to your question

You really should skip this section and just do it right. Find all calls to getTimestamp, getDate, setTimestamp, setDate, etc - and fix them properly.

Because the correct answer to your question is: Nevermind, you need to go one level deeper and stop using this stuff.

However, in the interests of satisfying curiosity:

Timestamp stamp = new Timestamp(123, 7, 1, 10, 0, 0, 0); //this > represents 2023-08-01 10:00:00 (UTC)
stamp.toInstant() //returns 2023-08-01 08:00:00

No, this is all very incorrect. Your understanding of dates and times is wrong.

A Timestamp cannot represent human-reckoning (which is years, months, days, and so on). It represents millis-since-epoch and the notion of referring to some millis-since-epoch values in terms of days, hours, minutes (and a UTC timezone) is a human invention that is subtly incorrect. That's not how it works under the hood. And when you lose sight of that, you get confused.

Instant is the same thing: It represents millis-since-epoch and not days, months, hours, etc. Hence, any interaction with these types in terms of human reckoning, be it that constructor you are invoking where you pass human-reckoning, or that toString() output of Instant, is merely for human convenience, is likely to confuse, and is the result of an internal conversion at point of contact - internally nothing is stored as human reckoning with these types!

Knowing that we can figure out why you are getting 'weird' results: The human reckoning constructor of java.sql.Timestamp 'helpfully' assumes you are writing your human reckoning in the platform default timezone (which, evidently, for your system, currently has +02:00 offset relative to UTC, i.e. most of mainland europe in summer for example).

Hence, it dutifully converts your human reckoning to that millis-since-epoch value that represents that exact moment in time when asking someone in Brussels 'hey, can you give me the date and time?' would result in the answer: "Sure mate! It's 2023, august 1st, and it's 10:00 in the morning exactly!" and stores that. It doesn't store timezones, and it doesn't store the notion of '10 o clock'. It just stores that big number: Your object has one field with value 1690876800000 and that is all it has (oversimplifying a bit, there are more fields, but they don't meaningfully affect this conclusion). It doesn't store the timezone and it doesn't store the human reckoning version (2023/08/01/10/00/00).

You then ask it to print itself, which causes it to convert 1690876800000 right back to human reckoning form. As you didn't provide a timezone when asking it to print itself, and it doesn't store one, the behaviour depends on what you're doing:

theInstanceOfJavaSqlTimestamp.toString()

produces the string value "2023-08-01 10:00:00.0", and it does this because its toString also 'helpfully' uses your platform default timezone which is still Europe/Amsterdam. This might make you think it dutifully stored '10 o clock' - it didn't.

Instead, Instant is 'better' in that it tries to avoid the confusion and avoids creating the impression it is storing timestamp info:

theInstanceOfJavaSqlTimestamp.toInstant().toString()

The toInstant() call just gives you an instant whose one field with epoch-millis still has the same 1690876800000 value stored inside. However, its toString() impl chooses to render this value to your eyeballs by converting it on-the-fly to human reckoning just like java.sql.Timestamp's toString(), but it uses UTC instead of 'platform local timezone'.

That doesn't change this simple fact: These 2 objects represent the exact same instant in time. It's just different takes on 'how should the toString() method show useful information to a human debugger'.

What I want is: 2023-08-01 12:00:00 I was able to get the result with:

This is fundamentally wrong - Instant and Timestamp simply do not work that way. This question is non-sensical. Your Timestamp represents 1690876800000. That's all it represents. Given that we're talking epoch millis, the only way it is sensible to say: I want it to represent [human reckoning values here], is if you include a full timezone (Europe/Amsterdam style, not CEST style, which aren't sufficient to uniquely determine times and dates).

If you don't have a timezone or that isn't what you're trying to represent, then Timestamp simply cannot be used AT ALL, and instead you need to use LocalDateTime, which is the only type that can properly represent human reckoning without a timezone!

If you want a nice programmatic interface to construction java.sql.Timestamp instances that represent what you want, use java.time and convert to the broken (java.sql.Timestamp, java.util.Date, etc) types as late in the process as possible:

Instant nowUtc = Instant.now();
ZoneId europeZone = ZoneId.of("Europe/Berlin");
ZonedDateTime berlinTime = ZonedDateTime.ofInstant(nowUtc, europeZone);

This code can be written a lot more simply:

ZoneId europeZone = ZoneId.of("Europe/Berlin");
ZonedDateTime berlinTime = ZonedDateTime.now(europeZone);

Converting this to a Timestamp does not make sense because j.s.Timestamp doesn't contain timezone info. If your database column does, then the only proper way to do it is to use .setObject(colIdxOrName, instanceOfOffsetDateTime). Having to use OffsetDateTime is painful, but it's a consequence of SQL also being confused and broken in regards to timezones (it thinks offsets are timezones. They aren't, and calling them timezones is very confusing, but, here we are). You can try calling .setObject() with an instance of ZDT, but if that doesn't work, you'd have to call:

ZoneId europeZone = ZoneId.of("Europe/Berlin");
ZonedDateTime berlinTime = ZonedDateTime.now(europeZone);
preparedStatement.setObject(berlinTime.toOffsetDateTime());

If you must have it in Timestamp form, note that timezones are right out and this code will subtly break on you later. Closest you can get:

Instant now = Instant.now();
// FORCIBLY SET YOUR SYSTEM TO BERLIN TIME!
// STUFF WILL BREAK IF SYSTEM TZ IS EVER CHANGED!
java.sql.Timestamp ts = java.sql.Timestamp.from(now);

j.s.Timestamp applies the platform local timezone whether you want it to or not.

rzwitserloot
  • 85,357
  • 5
  • 51
  • 72
  • A very minor point: there is no need to place a ResultSet in a try-with-resources statement, since closing a Statement [closes that Statement’s ResultSet](https://docs.oracle.com/en/java/javase/20/docs/api/java.sql/java/sql/Statement.html#close()). – VGR Aug 22 '23 at 16:40
1

Forget about java.sql.TIMESTAMP

The Answer by rzwitserloot is entirely correct. But let me put it more simply:

Stop using Timestamp.

Java has two frameworks for date-time handling: (a) a terribly flawed set of classes added to the early versions of Java, and (b) the java.time classes added to Java 8. The first is now legacy, entirely supplanted by the second.

Those legacy classes include java.sql.Timestamp. So, stop using that class Timestamp.

UTC value

You said you want a value of 2023-08-01 12:00:00 (UTC).

Pass the various parts.

LocalDateTime ldt = LocalDateTime.of( 2023 , 8 , 1 , 12 , 0 , 0 ) ;
OffsetDateTime odt = ldt.atOffset( ZoneOffset.UTC ) ;

Or, parse a string where Z on the end indicates an offset from UTC of zero hours-minutes-days.

Instant instant = Instant.parse ( "2023-08-01T12:00:00Z" ) ;

Database

For database work in JDBC 4.2+, use:

  • The OffsetDateTime class for columns of SQL standard type TIMESTAMP WITH TIME ZONE.
  • The LocalDateTime class For columns of SQL standard type TIMESTAMP WITHOUT TIME ZONE.

If handed a Timestamp object by code not yet updated to java.time, immediately convert to java.time.Instant.

Instant instant = myJavaSqlTimestamp.toInstant() ;

To send that value to a database column of SQL standard type TIMESTAMP WITH TIME ZONE:

OffsetDateTime odt = instant.atOffset( ZoneOffset.UTC ) ;
myPreparedStatement.setObject( … , odt ) ;

Retrieval:

OffsetDateTime odt = myResultSet.getObject( … , OffsetDateTime.class ) ;

For presentation to a user, apply a time zone (ZoneId) to get a ZonedDateTime. From there, generate text by using a DateTimeFormatter such as that produced by DateTimeFormatter.ofLocalizedDateTime.

Basil Bourque
  • 303,325
  • 100
  • 852
  • 1,154