Repository: BurntSushi/jiff (language: rust)

Issue:
serde support: `TimeZone` or `Offset`
Hey there! Thanks for this incredible new crate!

I'm using it in a new project, but I'm having difficulties due to the `TimeZone` and `Offset` types lacking `serde` implementations. I can understand why a `TimeZone` might not have it (the design document was very convincing). 

However, is it intentional that `Offset` doesn't have one? This seems like it'd just serialize that internal integer. (The `define_ranged!` machine does look mildly complicated here, of course).

Please let me know if I'm missing anything, or there's something else that might be better for serializing a user's timezone.

Once again, thanks for the new contribution to the ecosystem! :) 


Discussion / hints:
Can you say more about why you're serializing these types separate from the datetime types?
I'm building a Discord bot for friends and want to keep things simple by tying them with their time zones.

Since I'm already using `jiff` for its more commonplace datetime-focused types, it works out nicely since I don't need to write a bunch of conversions from some other timezone type!

Please let me know if that helps enough. I'm happy to give code examples! :)
Right, so you should be able to use `Zoned` for that. That is, it will include the time zone _and_ offset when serializing it. Most datetime libraries don't do this, so you might be used to serializing the time zone separately.

But reading closer, it looks like you want to associate the time zone itself with the user outside the context of a datetime. Which I think makes sense. Can you say though why you want to serialize an offset instead of just a time zone?

In terms of technical implementation, there isn't any problem with adding a serde impl for `Offset`. Whether to add it or not is really about API design and being careful about avoiding encouraging folks to do the wrong thing. So for the `Offset` specifically, I would need to think about it.

But for `TimeZone`, that's harder, because there just simply may not be a compact serializable representation of it.

Instead, I would ask you to consider storing a IANA time zone identifier for each of your friends. It might be missing if you don't know it. Then you would just call `TimeZone::get` on the stored string when you need the actual time zone. Those calls are cached. And you can get the identifier from a time zone with `TimeZone::iana_name` if one exists.

Does that help?
Great, thank you!

My idea for using an `Offset` over `TimeZone` is that a `TimeZone` has attributes that change, but the bot is built around one central pillar: it must store the "availability" of people, then compare the availabilities of all users when a user sends a command to the bot. 

Currently, I do this by storing a `Vec<Span>` alongside the user, but that list can sometimes be empty when the user is always available. Here's how that looks:

```rust
/// A representation of someone, with some of their details important to
/// knowing when to ping.
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Person {
    user_id: UserId,
    timezone: Offset,
    /// A list of timespans where they're unavailable (gone).
    gone: Vec<Unavailability>,
}

impl Person {
    /// Given a `UserId`, creates a new person with "default" availability
    /// and timezone (i.e. both blank).
    pub fn new(user_id: UserId) -> Self {
        Self {
            user_id,
            timezone: Offset::UTC,
            gone: Vec::new(),
        }
    }
}

/// A span of time where someone isn't able to play.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Unavailability {
    span: Span,
}

```

For this code to work, I need the user's timezone to be stored independently of their availability. Otherwise, I can't convert their spans if they change their timezone. Using `Offset` here isn't too important, but I know it's easier to serialize a simple offset than encoding the complexities of a timezone. 

It's also less fallible, as I don't need to 'convert' the list of Spans to the timezone each time I try to use them. (If I didn't do that with a `TimeZone`, the spans could be wrong from moment-to-moment.)

## Timezone Identifiers

I wouldn't mind storing a timezone identifier, so long as I can easily find them. Can I get it from a given `Offset`? A `Zoned`? Or even a `TimeZone` itself? If not, I'll likely have to make my own type, which is something I really want to avoid! 😄

Would it make sense to store a string? Would it be safe? Thank you! 
My take is that you should 100% be storing an IANA time zone identifier. And yes, it should be stored as a string. And no, you cannot get an IANA time zone identifier from an offset because there may be many IANA time zone identifiers for the same offset. Instead, it's the reverse: you get an offset _from_ a time zone _and_ either a `Timestamp` (instant in time) or a `civil::DateTime` (an end user's local time). How you get an IANA time zone identifier from an end user is I guess your job. How do you get their offset? You might need to ask them.

Using an IANA time zone identifier actually seems _critical_ to your use case here, to the point that storing an offset would be completely wrong. For example, for where I live, my IANA time zone identifier is _always_ `America/New_York`. The only way that would change is if I physically moved. But my _offset_ changes twice per year: it goes from `-05` to `-04` in the late winter (this year it was March 10, 2024) and `-04` to `-05` in mid-autumn (this year it will be November 3, 2024). This is true in many locations in the world and is known as daylight saving time. If you only capture the offset for each user at the present time, all of your calculations will be wrong once they enter or leave daylight saving time. But if you use an IANA time zone identifier and the `Zoned` data type, then you literally don't need to worry about this at all. Jiff will handle everything and should actually make it impossible for you to get it wrong.
Storing an offset is definitely wrong to some degree! To address the issues you mentioned, I'll probably make a serde-impl'd type that holds a String and interfaces with `jiff` to check the `Span`s when they're needed. 

Nonetheless, I'd love to see some form of serialization. So, assuming you'd want to serialize in IANA form, that leaves just two questions: 

- Can `jiff` currently get an IANA identifier from each 'variant' of `TimeZone`? (Or, in other words, should `jiff` guess a IANA ident for the POSIX/offset variants, and alternatively, do all variants have a consistent String representation?) 
- If not, can `TimeZone` expose its inner `TimeZoneTzif` when present? 

Answering these questions would solve this issue. However, for my simple project, using String timezone identifiers and `Timestamp` in the `Span` list would be an adequate solution. Thank you for your assistance! 
> Can jiff currently get an IANA identifier from each 'variant' of TimeZone?

No, it can't. That's why `TimeZone::iana_name` returns an option. It is fundamentally impossible to always have an IANA identifier. But that is the happy path. A missing TZ identifier is a generally a sub-optimal situation.

As for exposing the inner TZif, it is in theory possible, but it's probably a bad idea. I'm more likely to go the route outlined in https://github.com/BurntSushi/jiff/issues/30. But making that "easy" via a serde impl is almost certainly the wrong thing to do.
I tend to agree on the `serde` part - deciding for users seems potentially harmful. 

The plan in #30 sounds pretty good! Please let me know if you see any movement there. I'll watch the issue in the meantime.
Going to close this out in favor of #30. (Although, from my perspective, it does seem like just an IANA tz identifier is sufficient here for the OP's use case.)
@BurntSushi I'd like to be able to store `TimeZone` in a database (e.g. as a configuration option belonging to a user, etc). I understand your point re IANA names not being applicable to e.g. offsets and generally speaking, I'd expect most use cases to favor only supporting IANA names. 

That said, from an application development perspective, there is no public type that says "this can only be IANA". So when going to implement a library that uses `jiff`, I'm not sure how to approach this. I can ask users to provide a `TimeZone` but only at runtime will they discover that non-IANA time zones fail when we e.g. call `iana_name` and it returns `None`--this isn't ideal.

If there's a better way of doing this already built into `jiff`, that's great. But so far, I haven't figured out a satisfactory solution.
I think you probably want to accept a `&str` and then do the lookup yourself to get a `TimeZone`.

Also, if you're storing zone aware datetimes in a DB, you can use the Display and FromStr impls on Zoned. They will do the right thing, including attaching the IANA zone name if it exists.

If this doesn't help, can you suggest an API change in Jiff that does what you want? If only as a way to crystalize what it is you're looking for and prevent misunderstanding.
> I think you probably want to accept a `&str` and then do the lookup yourself to get a `TimeZone`.

I still wouldn't have a way to store the `TimeZone` itself which means I need to store the string and do lookups whenever I want a `TimeZone`. Internally, I want to use `TimeZone` wherever possible since `jiff` is ubiquitous. 

I should also point out, if a user wants to use a non-IANA `TimeZone` that's also okay: I don't have a strong reason to limit that, it's just that if the only reliable string representation is IANA names then I'm also okay with that restriction provided I don't need to give up on strong typing altogether.

Because users of this library will already be using `jiff` (it's an intentional design decision to expose `jiff` types in its API), the more natural thing to do is provide strong typing (both as a library author and as a user of the library).

> If this doesn't help, can you suggest an API change in Jiff that does what you want?

The elephant in the room is `serde` of course. 🙂 I see you've mentioned that compact representation is challenging, yet it seems we have a solution for `Zoned` so I'm not completely clear on why it's so difficult. What I'd want is just the isolation of `TimeZone` from that triple in a format that I can store (so binary, etc is also fine).
Can you say more about your higher level use case? i.e., more detail than just that you want to store it in a database.
I'm not sure what additional detail you're looking for. Can you be more specific?
"storing in a database" is a means to solve a problem and not an end itself. So, what problem are trying to solve here?

When dealing with datetimes, and especially in contexts that motivate the API design of a datetime library, it is really important to understand the higher level use case.
> So, what problem are trying to solve here?

The design of a library that uses `jiff` in its public interface and also needs to persist e.g. configuration that contains said types. 

Is it possible to convert from a string to a `TimeZone` back to a string and then do that in reverse? It is, but it's not nice and the public API becomes stringly as a result. This is doubly gross when we consider `jiff` isn't an implementation detail, it's part of the API end users will use; they'll be inclined toward `jiff`'s types. So it's unnatural and error-prone to use `&str` where we mean `TimeZone` anyway. Likewise, manually converting a string from a storage medium is troublesome in the same way.

Taking a step back, while I agree that often IANA-names are "what you really want" I don't agree that it's correct to force that decision and the argument doesn't land as well when there's really no type for an IANA-only `TimeZone`; at least if there were I wouldn't have to resort to strings. Indeed, there are plenty of scenarios where a fixed offset which doesn't interact with DST is just as correct as an IANA name that will yield DST aware behavior. So to my mind, it's not right to force this decision on users unless I know that it's never correct to use an offset.
I appreciate the response. I do think it's going to be difficult to make progress here without concrete use cases. I recognize you probably feel like you've given enough detail, but it is very difficult for me to think about this in the context of the details you've given so far.

I'll re-open this issue for now.

Let me try this again: can you suggest an API addition or change to Jiff that would accommodate your use case? I asked this before but I don't think I really got a precise answer to this.
> can you suggest an API addition or change to Jiff that would accommodate your use case?

I can think of a few possibilities:

1. Provide implementations of `Serialize` and `DerserializeOwned` for `TimeZone` (I know I said this already, but it feels the most natural given that `serde` is already a feature of the crate; it's actually the most surprising thing thus far to see that virtually none of the important types actually support `serde`)
2. Provide a complementary type that's only allowed to be derived from valid IANA names (so we can avoid using strings)
3. Provide a representation of `TimeZone` that allows users to de/serialize on their own (for instance, `as_bytes` and `from_bytes` methods)

I'm happy to talk about more concrete use cases, but I do want to avoid reducing the conversation to "just use strings": that isn't the design path I'm hoping to pursue. It's also not technically correct for me to impose IANA names, for e.g. running schedules that don't care about DST awareness and instead are just as validly defined with an offset. So the most correct answer is the user should decide by providing a `TimeZone` of their choosing.

That said, I would accept `TimeZone` that are derived only from IANA names as a limitation, if and only if I could avoid using strings. This is where I'm going to draw a line in the sand in terms of philosophy: stringly typed interfaces should be avoidable. If you disagree, that's fair enough, but I hope you'll consider this specific case where a library is actively designing with `jiff` as part of its public API and its users are going to want to leverage `jiff` types as much as possible.
> it's actually the most surprising thing thus far to see that virtually none of the important types actually support serde

Wait what? The most important types in this crate are the datetime and duration types. All of them implement both Serialize and Deserialize when the serde feature is enabled.

> Provide implementations of Serialize and DerserializeOwned for TimeZone

But how do they work? This is the key thing missing. A `TimeZone` might literally just be a sequence of transitions. It might not be a single offset, or have an IANA time zone name or even be a POSIX time zone string. It could be none of those. `Zoned` makes this work because it isn't just an independent serialization of both a datetime and a time zone. There is an interplay there where an offset can be computed _for_ the given instant in time and that can be serialized. But if all you have is a `TimeZone`, then what is the serialization format when it is neither a simple offset nor does it have an IANA name?

This is exactly why I asked this question because I suspected there might be some confusion in the specific concrete details.

> Provide a complementary type that's only allowed to be derived from valid IANA names (so we can avoid using strings)

How would this type be used in Jiff? The problem is that a `Zoned` uses a `TimeZone` internally. So if this new stricter type is used to create a `Zoned`, then it will the best you can have is a method on `Zoned` that returns a `Option<ComplementaryType>`.

Alternatively, `Zoned` becomes generic over a time zone type, but now we're talking a very expansive change.

> Provide a representation of TimeZone that allows users to de/serialize on their own (for instance, as_bytes and from_bytes methods)

That's what #30 is intended to be. Or am I misunderstanding? Note that `from_bytes` already exists via `TimeZone::tzif`.
The overall idea here is that a `TimeZone` is very expansive. It doesn't really have an independent compact serialization for all possible values. So if your library is using a `TimeZone` and also wants to serialize that time zone somewhere _independent of an instant in time_, then there are some fundamental limitations facing that. The only way to donit correctly for all possible `TimeZone` values is either with TZif or some other format (like zic) that describes the actual time zone transition rules.

This is why the use case is important. If you want to serialize the time zone in a compact offset-or-iana format, then it probably makes sense for your library to define a more limited type representing that choice and then maybe convert to `TimeZone` when you need to interact with Jiff. Although I'm not sure.

Because we haven't been able to talk about use cases, the thing I still don't understand is why a `TimeZone` needs to be stored independent of a datetime. I get that you might want to store things like "this user's configured time zone is x." But that's a fundamentally more limited expression than what `TimeZone` supports, because end users don't work with the full set of time zone transition rules. They work with IANA tzids (well, technical users I guess) or, at worst, offsets that ignore DST.
> Note that from_bytes already exists via TimeZone::tzif.

Then we badly need `as_bytes`.

I think it's also worth noting that `chrono`'s version of `TimeZone` is compatible with `serde`. So we can see plainly here I'm not the first, and I'll wager I won't be the last person, to scratch their head in confusion when `serde` support is missing here. (I know you said the two most "important" types do support `serde`, but the distinction feels arbitrary as soon as you need one of the types that didn't make the cut...)

> I still don't understand is why a `TimeZone` needs to be stored independent of a datetime

If you thought that the only useful representation was a timestamp, you probably wouldn't have made `TimeZone` public, right? Here's where you've lost me: always needing a date-time expression to also have time zone is not something I can comprehend. Sometimes you really just want a time zone and nothing else. Maybe `TimeZone` is overloaded with too many responsibilities right now? (Should it be broken up into smaller, more focused types perhaps?)

> I get that you might want to store things like "this user's configured time zone is x."

Then I think it pretty clearly follows that the ability to store this and only this is useful. There's a fundamental contradiction between this and the previous sentence: a configured time zone has nothing to do with a timestamp per se and I certainly don't want to store some arbitrary timestamp just so I can capture the time zone.

> They work with IANA tzids (well, technical users I guess) or, at worst, offsets that ignore DST.

I would accept a type that's either IANA names or offsets; a subset of the internal kind enum for example. Either are valid for many of the use cases where you might need to store time zone data in my view.
> I think it's also worth noting that chrono's version of TimeZone is compatible with serde

Not really haha. I spilled a fair bit of words on this topic in the Jiff design and comparison docs. Chrono's implementations of its TimeZone trait in chrono proper all support serde, but _none_ of those correspond to IANA time zones. There is the chrono-tz and tzfile crates which provide their own TimeZone trait implementations, but neither of them support Serde.

Basically, you're running into a shortcoming of Chrono that let you easily do what is arguably the wrong thing in most cases (since you lose DST info). That gave the _appearance_ of an additional capability, but in reality it's more complicated than that.

> (I know you said the two most "important" types do support serde, but the distinction feels arbitrary as soon as you need one of the types that didn't make the cut...)

It's a lot more than two... It's not like it's an arbitrary choice or something. I think the only type that doesn't support serde at present but easily could is `Offset`. I exclude `TimeZone` from this because it _isn't_ easy to provide a serde implementation for it.

> If you thought that the only useful representation was a timestamp, you probably wouldn't have made TimeZone public, right? Here's where you've lost me: always needing a date-time expression to also have time zone is not something I can comprehend. Sometimes you really just want a time zone and nothing else. Maybe TimeZone is overloaded with too many responsibilities right now? (Should it be broken up into smaller, more focused types perhaps?)

I find this to be a somewhat frustrating response. I'm trying to tell you something I don't understand. I'll add my intent more explicitly: can you help me understand it better?

> Here's where you've lost me: always needing a date-time expression to also have time zone is not something I can comprehend.

That's not what I said though. I tried to be very careful with my wording, but perhaps I failed. Let me be more precise: given any arbitrary `TimeZone` value, the only way to turn it into an offset is to give it a concrete instant in time.

> Then I think it pretty clearly follows that the ability to store this and only this is useful. There's a fundamental contradiction between this and the previous sentence: a configured time zone has nothing to do with a timestamp per se and I certainly don't want to store some arbitrary timestamp just so I can capture the time zone.

Okay, I didn't say that... What I mean is that you might find it easier to define a more limited time zone type that is specific to user configuration. A Jiff `TimeZone` is more like a representation of a transformation than a simple user configuration setting. That is, all valid user configuration time zones should be mappable to a Jiff `TimeZone`, but not all Jiff `TimeZone` values are mappable to a simple user configuration setting.

> I would accept a type that's either IANA names or offsets; a subset of the internal kind enum for example. Either are valid for many of the use cases where you might need to store time zone data in my view.

I understand this, but we're going in circles. My previous comments asked for more details about this option. My sense is that a type like this would be better defined closer to the user configuration than in Jiff proper. 
My previous comments also provided some very specific concrete technical problems with your suggestions. But the conversation is veeeing back towards abstractions. Can we try to keep it concrete so that we can drive toward a specific solution to your problem?
> Not really haha. I spilled a fair bit of words on this topic in the Jiff design and comparison docs. Chrono's implementations of its TimeZone trait in chrono proper all support serde, but none of those correspond to IANA time zones. There is the chrono-tz and tzfile crates which provide their own TimeZone trait implementations, but neither of them support Serde.

Something about this is not right: I have a bunch of IANA names serialized to Postgres using `chrono` and `chrono_tz` (`serde_json::to_string` does exactly what you'd hope with `Tz`). So, we're clearly not talking about the same thing or perhaps there's a gap in that analysis?

My suspicion is that the "TimeZone" type is overloaded and that `jiff` could benefit from some layer of abstraction between its underlying concept, perhaps named differently, and the things that compose it, which are clearly useful in their own right.

With that, I unfortunately don't have more to add here that I haven't already and I'd like to leave space for other folks to chime in.
> Something about this is not right: I have a bunch of IANA names serialized to Postgres using chrono and chrono_tz (serde_json::to_string does exactly what you'd hope with Tz). So, we're clearly not talking about the same thing or perhaps there's a gap in that analysis?

Ah okay, the Tz type itself supports Serde. But `DateTime<Tz>` does not last I checked. If just Tz was working fine for you before, then I would definitely recommend defining your own time zone type as I suggested above. A Tz is literally only a handle to an IANA time zone.

> My suspicion is that the "TimeZone" type is overloaded and that jiff could benefit from some layer of abstraction between its underlying concept, perhaps named differently, and the things that compose it, which are clearly useful in their own right.

There are trade offs involved here. A Jiff time zone is in fact a single conceptual thing: it is a transformation mapping between civil times and instants via offset. Chrono exposes bits and bobs of this concept but provides no cohesive type. As a result, there is more flexibility in Chrono's design and it is easier to do incorrect things, or at least, harder to do the right thing. But not all abstractions are created equal. Jiff for example provides an abstraction over "time zone data embedded in binary" and "tome zone data read from zoneinfo on disk." Chrono doesn't give you that. You, the library user, have to assemble that logic yourself.

It is true that a Jiff time zone is one of many possible things, but each of those things corresponds to a time zone that is used in the same way. And its API is closed, meaning you can't extend the concept in your own code (unless you provide TZif data). Conversely, in Chrono, it is an open concept at the level of the type system. So each different kind of time zone is exposed and users can extend it with their own implementations of that trait.

I think it is a good idea to have a layer of abstraction between "end user configurable time zone" and "jiff time zone that is used to map between between instants and civil times via offsets." They just aren't the same thing. But I'm not quite seeing why the former should be in Jiff. It seems like something that should be defined specific to the application or use case. For example, you might not even ask an end user for a TZ id. Instead, you ask for a location and derive the IANA TZ id from it.
I'm re-reading the discussion now, especially the beginning. I think there are more questions to ask here:

> I still wouldn't have a way to store the TimeZone itself which means I need to store the string and do lookups whenever I want a TimeZone. Internally, I want to use TimeZone wherever possible since jiff is ubiquitous.

Can you say why, more specifically, accepting a `&str` and doing a lookup when you need a `TimeZone` doesn't work for you? Like is this a philosophical disagreement here? Or is there a specific technical problem preventing you from doing this?

> I should also point out, if a user wants to use a non-IANA TimeZone that's also okay: I don't have a strong reason to limit that, it's just that if the only reliable string representation is IANA names then I'm also okay with that restriction provided I don't need to give up on strong typing altogether.

I should have caught this earlier, but I actually think this might be the crux of the issue. Because "I want to accept any kind of `TimeZone` value" and "I want to store the `TimeZone` in a database" are somewhat exclusionary goals. Or at least, if you specifically want to accept any `TimeZone` value and absolutely must store in a DB, then the only correct way to do that is by potentially storing a lot of data in the case where a time zone isn't just a simple offset or IANA identifier.

-----

I would really like to help you drive toward a solution here. I am very interested in trying to understand use cases better so that Jiff becomes an obvious choice for you. And especially now before Jiff 1.0 where I have a bit more freedom to do breaking changes. If you'd like to work with me on this to help me better understand what you're trying to do, that would be great.
In an effort to try to make a concrete API suggestion that doesn't make fundamental alternations to the design of Jiff, what about adding a new serde convenience API to [`jiff::fmt::serde`](https://docs.rs/jiff/latest/jiff/fmt/serde/index.html)?

I guess the idea would be to add two new modules for dealing with (de)serializing `TimeZone` and `Option<TimeZone>`, but limited to IANA time zone identifiers, POSIX time zones or fixed offsets only.

For `Option<TimeZone>` serialization would fail if it's a TZif time zone without an IANA time zone identifier. (This is possible for some Unix system configurations where, e.g., `/etc/localtime` is a TZif file with no discoverable time zone identifier.) But I think it would otherwise always succeed. For deserialization, you'd get an error if it's an invalid format, unrecognized IANA time zone identifier (according to whatever Jiff's global time zone database says) or `None` if it's `null` or absent.

And `TimeZone` would work like `Option<TimeZone>`, but would be required.

There are definitely some assumptions built into this (like using the global time zone database), but I think it should generally work in most configurations. And of course, users can always define their own Serde helpers to do something different.

I like the idea of putting this as a Serde `with` helper instead of as `Serialize` and `Deserialize` impls on `TimeZone` directly because the type of serialization above isn't really describing the full time zone rules in the case of an IANA time zone identifier, but rather, just returning a _pointer_ to it. _And_ deserialization requires doing a global time zone database lookup, which is maybe a little sneaky. But maybe that's a bit too nitpicky and it would be better to just make `TimeZone` with Serde work directly.

This idea would also support more than IANA time zone identifiers. So users could write `-05:00` for example. Or even `EST5EDT,M3.2.0,M11.1.0`. Since that reflects nearly the full "range" of time zones supported by `jiff::tz::TimeZone`. I guess if you wanted IANA time zone identifiers only, then it might be reasonable to ask users to implement their own type for that. (Or we could add a separate `with` helper for that, where serialization fails if the `TimeZone` isn't an IANA time zone with a present identifier.)

@maxcountryman What do you think?
I think for now, I am planning on:

* Adding `jiff::fmt::temporal::DateTimeParser::{parse_time_zone,parse_time_zone_with}` which returns a `Result<TimeZone, Error>`. The `_with` variant will accept a `&TimeZoneDatabase`.
* Adding `jiff::fmt::temporal::DateTimePrinter::{time_zone_to_string,print_time_zone}`.
* Adding `jiff::fmt::serde` helpers for applying the above two functions to (de)serialize `TimeZone` and `Option<TimeZone>`.

The parsing and printing functions would essentially adhere to the Temporal ISO 8601 grammar for what time zones to support _in addition_ to POSIX TZ strings.

For the case where a `TimeZone` has no meaningfully succinct serialized form (as in a system time zone with no identifiable IANA time zone identifier), then I think I'm going to raise an error for now. I think any other behavior would require some guesswork that could be wrong in some cases.

So for this initial support, I'm going to avoid adding `Serialize` and `Deserialize` impls on `TimeZone`, as well as avoid `FromStr` and `Display` impls for `TimeZone` as well. It's conceivable we could add those in the future, but I'm still a little iffy on it. At least with the approach above, it's opt-in and ties it more to the Temporal grammar than it does to the notion of time zones itself.
Hi BurntSushi, thank you for the great library!

Just a bit of context from usage in the wild: for serialisation and stricter type-checking we ended up making our own types that cover offsets and basically a restricted list of IANA time zones. This worked fine and allowed us to avoid things like custom timezones which we don't need.

Avoiding `Serialize` and `Deserialize` impls on `TimeZone` seems the right call.

Kind of related, but not sure it's worth its own issue: on the topic of `Offset` instead, I have felt the need for a serialisable `(Timestamp, Offset)` type as it is unfortunately how most timestamps are used in the wild (just parsing it into a `Timestamp` isn't sufficient, as the offset allows you to turn it into a date correctly, and parsing it into a `Zoned` does and should fail).
The manual parser is still flexible enough to handle this, so that's been helpful, and I understand it might have been a design choice to push people toward Zoned.

Thanks again! `jiff` has genuinely been super helpful both in guiding and implementing our date-time handling logic.
> Thanks again! jiff has genuinely been super helpful both in guiding and implementing our date-time handling logic.

<3

> Kind of related, but not sure it's worth its own issue: on the topic of Offset instead, I have felt the need for a serialisable (Timestamp, Offset) type as it is unfortunately how most timestamps are used in the wild (just parsing it into a Timestamp isn't sufficient, as the offset allows you to turn it into a date correctly, and parsing it into a Zoned does and should fail).
The manual parser is still flexible enough to handle this, so that's been helpful, and I understand it might have been a design choice to push people toward Zoned.

Thanks for sharing this. I don't think it's quite actionable enough on its own though. If you want to get into the weeds on this and what specifically you're trying to do, I'd love to hear more about what you're trying to do in a [discussion](https://github.com/BurntSushi/jiff/discussions/). :-)

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11a6233..9dd8662 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ TODO
 
 Enhancements:
 
+* [#89](https://github.com/BurntSushi/jiff/issues/89):
+Opt-in support for using Serde with `jiff::tz::TimeZone` has been added.
 * [#227](https://github.com/BurntSushi/jiff/issues/227):
 The `civil::ISOWeekDate` API has been beefed up with a few convenience methods.
 
diff --git a/src/fmt/mod.rs b/src/fmt/mod.rs
index 7418c00..442ef60 100644
--- a/src/fmt/mod.rs
+++ b/src/fmt/mod.rs
@@ -220,6 +220,13 @@ impl<W: core::fmt::Write> Write for StdFmtWrite<W> {
     }
 }
 
+impl<W: Write> core::fmt::Write for StdFmtWrite<W> {
+    #[inline]
+    fn write_str(&mut self, string: &str) -> Result<(), core::fmt::Error> {
+        self.0.write_str(string).map_err(|_| core::fmt::Error)
+    }
+}
+
 /// An extension trait to `Write` that provides crate internal routines.
 ///
 /// These routines aren't exposed because they make use of crate internal
diff --git a/src/fmt/offset.rs b/src/fmt/offset.rs
index 3b1a13e..2057b79 100644
--- a/src/fmt/offset.rs
+++ b/src/fmt/offset.rs
@@ -292,12 +292,13 @@ impl core::fmt::Debug for Numeric {
 pub(crate) struct Parser {
     zulu: bool,
     subminute: bool,
+    subsecond: bool,
 }
 
 impl Parser {
     /// Create a new UTC offset parser with the default configuration.
     pub(crate) const fn new() -> Parser {
-        Parser { zulu: true, subminute: true }
+        Parser { zulu: true, subminute: true, subsecond: true }
     }
 
     /// When enabled, the `z` and `Z` designators are recognized as a "zulu"
@@ -315,11 +316,26 @@ impl Parser {
     /// are supported. Specifically, when enabled, nanosecond precision is
     /// supported.
     ///
-    /// When disabled, offsets must be integral minutes.
+    /// When disabled, offsets must be integral minutes. And the `subsecond`
+    /// option is ignored.
     pub(crate) const fn subminute(self, yes: bool) -> Parser {
         Parser { subminute: yes, ..self }
     }
 
+    /// When enabled, offsets with precision greater than integral seconds
+    /// are supported. Specifically, when enabled, nanosecond precision is
+    /// supported. Note though that when a fractional second is found, it is
+    /// used to round to the nearest second. (Jiff's `Offset` type only has
+    /// second resolution.)
+    ///
+    /// When disabled, offsets must be integral seconds (or integrate minutes
+    /// if the `subminute` option is disabled as well).
+    ///
+    /// This is ignored if `subminute` is disabled.
+    pub(crate) const fn subsecond(self, yes: bool) -> Parser {
+        Parser { subsecond: yes, ..self }
+    }
+
     /// Parse an offset from the beginning of `input`.
     ///
     /// If no offset could be found or it was otherwise invalid, then an error
@@ -491,6 +507,18 @@ impl Parser {
             })?;
         numeric.seconds = Some(seconds);
 
+        // If subsecond resolution is not supported, then we're done here.
+        if !self.subsecond {
+            if input.get(0).map_or(false, |&b| b == b'.' || b == b',') {
+                return Err(err!(
+                    "subsecond precision for UTC numeric offset {original:?} \
+                     is not enabled in this context (must provide only \
+                     integral minutes or seconds)",
+                ));
+            }
+            return Ok(Parsed { value: numeric, input });
+        }
+
         // Parse an optional fractional component.
         let Parsed { value: nanoseconds, input } =
             parse_temporal_fraction(input).with_context(|| {
diff --git a/src/fmt/rfc9557.rs b/src/fmt/rfc9557.rs
index 817992f..09afad6 100644
--- a/src/fmt/rfc9557.rs
+++ b/src/fmt/rfc9557.rs
@@ -246,11 +246,6 @@ impl Parser {
         }
         input = &input[1..];
 
-        // This does a quick check for an `=` after the opening `[` but before
-        // the closing `]`. If one is found, then we know we cannot have a
-        // time zone annotation, but a more generic key-value annotation. We
-        // could do this while parsing below, but I found this to be simpler.
-
         let critical = input.starts_with(b"!");
         if critical {
             input = &input[1..];
diff --git a/src/fmt/serde.rs b/src/fmt/serde.rs
index 020882a..b3064f0 100644
--- a/src/fmt/serde.rs
+++ b/src/fmt/serde.rs
@@ -36,8 +36,11 @@ easy copy & paste.
     * [`nanosecond`](self::timestamp::millisecond)
         * [`jiff::fmt::serde::timestamp::nanosecond::required`](self::timestamp::nanosecond::required)
         * [`jiff::fmt::serde::timestamp::nanosecond::optional`](self::timestamp::nanosecond::optional)
+* [`tz`]
+    * [`jiff::fmt::serde::tz::required`](self::tz::required)
+    * [`jiff::fmt::serde::tz::optional`](self::tz::optional)
 
-# Example
+# Example: timestamps as an integer
 
 This example shows how to deserialize an integer number of seconds since the
 Unix epoch into a [`Timestamp`](crate::Timestamp). And the reverse operation
@@ -60,7 +63,7 @@ assert_eq!(serde_json::to_string(&got)?, json);
 # Ok::<(), Box<dyn std::error::Error>>(())
 ```
 
-# Example: optional support
+# Example: optional timestamp support
 
 And this example shows how to use an `Option<Timestamp>` instead of a
 `Timestamp`. Note that in this case, we show how to roundtrip the number of
@@ -257,9 +260,7 @@ pub mod duration {
             ) -> Result<S::Ok, S::Error> {
                 match *duration {
                     None => se.serialize_none(),
-                    Some(ref duration) => {
-                        se.collect_str(&CompactDuration(duration))
-                    }
+                    Some(ref duration) => required(duration, se),
                 }
             }
         }
@@ -403,7 +404,7 @@ pub mod span {
             ) -> Result<S::Ok, S::Error> {
                 match *span {
                     None => se.serialize_none(),
-                    Some(ref span) => se.collect_str(&CompactSpan(span)),
+                    Some(ref span) => required(span, se),
                 }
             }
         }
@@ -1079,6 +1080,208 @@ pub mod timestamp {
     }
 }
 
+/// Convenience routines for (de)serializing [`TimeZone`](crate::tz::TimeZone)
+/// values.
+///
+/// The `required` and `optional` sub-modules each provide serialization and
+/// deserialization routines. They are meant to be used with Serde's
+/// [`with` attribute].
+///
+/// # Advice
+///
+/// Serializing time zones is useful when you want to accept user configuration
+/// selecting a time zone to use. This might be beneficial when one cannot rely
+/// on a system's time zone.
+///
+/// Note that when deserializing time zones that are IANA time zone
+/// identifiers, Jiff will automatically use the implicit global database to
+/// resolve the identifier to an actual time zone. If you do not want to use
+/// Jiff's global time zone database for this, you'll need to write your own
+/// Serde integration.
+///
+/// [`with` attribute]: https://serde.rs/field-attrs.html#with
+///
+/// # Example
+///
+/// ```
+/// use jiff::tz::TimeZone;
+///
+/// #[derive(Debug, serde::Deserialize, serde::Serialize)]
+/// struct Record {
+///     #[serde(with = "jiff::fmt::serde::tz::required")]
+///     tz: TimeZone,
+/// }
+///
+/// let json = r#"{"tz":"America/Nuuk"}"#;
+/// let got: Record = serde_json::from_str(&json)?;
+/// assert_eq!(got.tz, TimeZone::get("America/Nuuk")?);
+/// assert_eq!(serde_json::to_string(&got)?, json);
+///
+/// # Ok::<(), Box<dyn std::error::Error>>(())
+/// ```
+pub mod tz {
+    use serde::de;
+
+    use crate::fmt::{temporal, StdFmtWrite};
+
+    struct TemporalTimeZone<'a>(&'a crate::tz::TimeZone);
+
+    impl<'a> core::fmt::Display for TemporalTimeZone<'a> {
+        fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
+            static PRINTER: temporal::DateTimePrinter =
+                temporal::DateTimePrinter::new();
+            PRINTER
+                .print_time_zone(self.0, StdFmtWrite(f))
+                .map_err(|_| core::fmt::Error)
+        }
+    }
+
+    /// A required visitor for `TimeZone`.
+    struct Visitor;
+
+    impl<'de> de::Visitor<'de> for Visitor {
+        type Value = crate::tz::TimeZone;
+
+        fn expecting(
+            &self,
+            f: &mut core::fmt::Formatter,
+        ) -> core::fmt::Result {
+            f.write_str(
+                "a string representing a time zone via an \
+                 IANA time zone identifier, fixed offset from UTC \
+                 or a POSIX time zone string",
+            )
+        }
+
+        #[inline]
+        fn visit_bytes<E: de::Error>(
+            self,
+            value: &[u8],
+        ) -> Result<crate::tz::TimeZone, E> {
+            static PARSER: temporal::DateTimeParser =
+                temporal::DateTimeParser::new();
+            PARSER.parse_time_zone(value).map_err(de::Error::custom)
+        }
+
+        #[inline]
+        fn visit_str<E: de::Error>(
+            self,
+            value: &str,
+        ) -> Result<crate::tz::TimeZone, E> {
+            self.visit_bytes(value.as_bytes())
+        }
+    }
+
+    /// A generic optional visitor for `TimeZone`.
+    struct OptionalVisitor<V>(V);
+
+    impl<'de, V: de::Visitor<'de, Value = crate::tz::TimeZone>>
+        de::Visitor<'de> for OptionalVisitor<V>
+    {
+        type Value = Option<crate::tz::TimeZone>;
+
+        fn expecting(
+            &self,
+            f: &mut core::fmt::Formatter,
+        ) -> core::fmt::Result {
+            f.write_str(
+                "a string representing a time zone via an \
+                 IANA time zone identifier, fixed offset from UTC \
+                 or a POSIX time zone string",
+            )
+        }
+
+        #[inline]
+        fn visit_some<D: de::Deserializer<'de>>(
+            self,
+            de: D,
+        ) -> Result<Option<crate::tz::TimeZone>, D::Error> {
+            de.deserialize_str(self.0).map(Some)
+        }
+
+        #[inline]
+        fn visit_none<E: de::Error>(
+            self,
+        ) -> Result<Option<crate::tz::TimeZone>, E> {
+            Ok(None)
+        }
+    }
+
+    /// (De)serialize a required [`TimeZone`](crate::tz::TimeZone).
+    pub mod required {
+        /// Serialize a required [`TimeZone`](crate::tz::TimeZone).
+        ///
+        /// This will result in an IANA time zone identifier, fixed offset or a
+        /// POSIX time zone string.
+        ///
+        /// This can return an error in some cases when the `TimeZone` has no
+        /// succinct string representation. For example, when the `TimeZone` is
+        /// derived from a system `/etc/localtime` for which no IANA time zone
+        /// identifier could be found.
+        #[inline]
+        pub fn serialize<S: serde::Serializer>(
+            tz: &crate::tz::TimeZone,
+            se: S,
+        ) -> Result<S::Ok, S::Error> {
+            if !tz.has_succinct_serialization() {
+                return Err(<S::Error as serde::ser::Error>::custom(
+                    "time zones without IANA identifiers that aren't either \
+                     fixed offsets or a POSIX time zone can't be serialized \
+                     (this typically occurs when this is a system time zone \
+                      derived from `/etc/localtime` on Unix systems that \
+                      isn't symlinked to an entry in `/usr/share/zoneinfo`)",
+                ));
+            }
+            se.collect_str(&super::TemporalTimeZone(tz))
+        }
+
+        /// Deserialize a required [`TimeZone`](crate::tz::TimeZone).
+        ///
+        /// This will attempt to parse an IANA time zone identifier, a fixed
+        /// offset or a POSIX time zone string.
+        #[inline]
+        pub fn deserialize<'de, D: serde::Deserializer<'de>>(
+            de: D,
+        ) -> Result<crate::tz::TimeZone, D::Error> {
+            de.deserialize_str(super::Visitor)
+        }
+    }
+
+    /// (De)serialize an optional [`TimeZone`](crate::tz::TimeZone).
+    pub mod optional {
+        /// Serialize an optional [`TimeZone`](crate::tz::TimeZone).
+        ///
+        /// This will result in an IANA time zone identifier, fixed offset or a
+        /// POSIX time zone string.
+        ///
+        /// This can return an error in some cases when the `TimeZone` has no
+        /// succinct string representation. For example, when the `TimeZone` is
+        /// derived from a system `/etc/localtime` for which no IANA time zone
+        /// identifier could be found.
+        #[inline]
+        pub fn serialize<S: serde::Serializer>(
+            tz: &Option<crate::tz::TimeZone>,
+            se: S,
+        ) -> Result<S::Ok, S::Error> {
+            match *tz {
+                None => se.serialize_none(),
+                Some(ref tz) => super::required::serialize(tz, se),
+            }
+        }
+
+        /// Deserialize an optional [`TimeZone`](crate::tz::TimeZone).
+        ///
+        /// This will attempt to parse an IANA time zone identifier, a fixed
+        /// offset or a POSIX time zone string.
+        #[inline]
+        pub fn deserialize<'de, D: serde::Deserializer<'de>>(
+            de: D,
+        ) -> Result<Option<crate::tz::TimeZone>, D::Error> {
+            de.deserialize_option(super::OptionalVisitor(super::Visitor))
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use crate::{
diff --git a/src/fmt/temporal/mod.rs b/src/fmt/temporal/mod.rs
index 63337a7..b873d2c 100644
--- a/src/fmt/temporal/mod.rs
+++ b/src/fmt/temporal/mod.rs
@@ -175,7 +175,7 @@ use crate::{
     error::Error,
     fmt::Write,
     span::Span,
-    tz::{Disambiguation, Offset, OffsetConflict, TimeZoneDatabase},
+    tz::{Disambiguation, Offset, OffsetConflict, TimeZone, TimeZoneDatabase},
     SignedDuration, Timestamp, Zoned,
 };
 
@@ -764,6 +764,103 @@ impl DateTimeParser {
         Ok(time)
     }
 
+    /// Parses a string representing a time zone into a [`TimeZone`].
+    ///
+    /// This will parse one of three different categories of strings:
+    ///
+    /// 1. An IANA Time Zone Database identifier. For example,
+    /// `America/New_York` or `UTC`.
+    /// 2. A fixed offset. For example, `-05:00` or `-00:44:30`.
+    /// 3. A POSIX time zone string. For example, `EST5EDT,M3.2.0,M11.1.0`.
+    ///
+    /// # Example
+    ///
+    /// This shows a few examples of parsing different kinds of time zones:
+    ///
+    /// ```
+    /// use jiff::{fmt::temporal::DateTimeParser, tz::{self, TimeZone}};
+    ///
+    /// static PARSER: DateTimeParser = DateTimeParser::new();
+    ///
+    /// assert_eq!(
+    ///     PARSER.parse_time_zone("-05:00")?,
+    ///     TimeZone::fixed(tz::offset(-5)),
+    /// );
+    /// assert_eq!(
+    ///     PARSER.parse_time_zone("+05:00:01")?,
+    ///     TimeZone::fixed(tz::Offset::from_seconds(5 * 60 * 60 + 1).unwrap()),
+    /// );
+    /// assert_eq!(
+    ///     PARSER.parse_time_zone("America/New_York")?,
+    ///     TimeZone::get("America/New_York")?,
+    /// );
+    /// assert_eq!(
+    ///     PARSER.parse_time_zone("Israel")?,
+    ///     TimeZone::get("Israel")?,
+    /// );
+    /// assert_eq!(
+    ///     PARSER.parse_time_zone("EST5EDT,M3.2.0,M11.1.0")?,
+    ///     TimeZone::posix("EST5EDT,M3.2.0,M11.1.0")?,
+    /// );
+    ///
+    /// // Some error cases!
+    /// assert!(PARSER.parse_time_zone("Z").is_err());
+    /// assert!(PARSER.parse_time_zone("05:00").is_err());
+    /// assert!(PARSER.parse_time_zone("+05:00:01.5").is_err());
+    /// assert!(PARSER.parse_time_zone("Does/Not/Exist").is_err());
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    pub fn parse_time_zone<'i, I: AsRef<[u8]>>(
+        &self,
+        input: I,
+    ) -> Result<TimeZone, Error> {
+        self.parse_time_zone_with(crate::tz::db(), input)
+    }
+
+    /// Parses a string representing a time zone into a [`TimeZone`] and
+    /// performs any time zone database lookups using the [`TimeZoneDatabase`]
+    /// given.
+    ///
+    /// This is like [`DateTimeParser::parse_time_zone`], but uses the time
+    /// zone database given instead of the implicit global time zone database.
+    ///
+    /// This will parse one of three different categories of strings:
+    ///
+    /// 1. An IANA Time Zone Database identifier. For example,
+    /// `America/New_York` or `UTC`.
+    /// 2. A fixed offset. For example, `-05:00` or `-00:44:30`.
+    /// 3. A POSIX time zone string. For example, `EST5EDT,M3.2.0,M11.1.0`.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::{fmt::temporal::DateTimeParser, tz::{self, TimeZone}};
+    ///
+    /// static PARSER: DateTimeParser = DateTimeParser::new();
+    ///
+    /// let db = jiff::tz::db();
+    /// assert_eq!(
+    ///     PARSER.parse_time_zone_with(db, "America/New_York")?,
+    ///     TimeZone::get("America/New_York")?,
+    /// );
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    ///
+    /// See also the example for [`DateTimeParser::parse_zoned_with`] for a
+    /// more interesting example using a time zone database other than the
+    /// default.
+    pub fn parse_time_zone_with<'i, I: AsRef<[u8]>>(
+        &self,
+        db: &TimeZoneDatabase,
+        input: I,
+    ) -> Result<TimeZone, Error> {
+        let input = input.as_ref();
+        let parsed = self.p.parse_time_zone(input)?.into_full()?;
+        parsed.into_time_zone(db)
+    }
+
     /// Parse a Temporal datetime string into [`Pieces`].
     ///
     /// This is a lower level routine meant to give callers raw access to the
@@ -1295,6 +1392,44 @@ impl DateTimePrinter {
         buf
     }
 
+    /// Format a `TimeZone` into a string.
+    ///
+    /// This is a convenience routine for [`DateTimePrinter::print_time_zone`].
+    ///
+    /// # Errors
+    ///
+    /// In some rare cases, serialization may fail when there is no succinct
+    /// representation of a time zone. One specific case in which this
+    /// occurs is when `TimeZone` is a user's system time zone derived from
+    /// `/etc/localtime`, but where an IANA time zone identifier could not
+    /// be found. This can occur, for example, when `/etc/localtime` is not
+    /// symlinked to an entry in `/usr/share/zoneinfo`.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::{fmt::temporal::DateTimePrinter, tz::{self, TimeZone}};
+    ///
+    /// const PRINTER: DateTimePrinter = DateTimePrinter::new();
+    ///
+    /// // IANA time zone
+    /// let tz = TimeZone::get("US/Eastern")?;
+    /// assert_eq!(PRINTER.time_zone_to_string(&tz)?, "US/Eastern");
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[cfg(feature = "alloc")]
+    pub fn time_zone_to_string(
+        &self,
+        tz: &TimeZone,
+    ) -> Result<alloc::string::String, Error> {
+        let mut buf = alloc::string::String::with_capacity(4);
+        // Writing to a `String` itself will never fail, but this could fail
+        // as described above in the docs.
+        self.print_time_zone(tz, &mut buf)?;
+        Ok(buf)
+    }
+
     /// Format `Pieces` of a Temporal datetime.
     ///
     /// This is a convenience routine for [`DateTimePrinter::print_pieces`]
@@ -1590,6 +1725,85 @@ impl DateTimePrinter {
         self.p.print_time(time, wtr)
     }
 
+    /// Print a `TimeZone`.
+    ///
+    /// This will emit one of three different categories of strings:
+    ///
+    /// 1. An IANA Time Zone Database identifier. For example,
+    /// `America/New_York` or `UTC`.
+    /// 2. A fixed offset. For example, `-05:00` or `-00:44:30`.
+    /// 3. A POSIX time zone string. For example, `EST5EDT,M3.2.0,M11.1.0`.
+    ///
+    /// # Differences with RFC 9557 annotations
+    ///
+    /// Jiff's [`Offset`] has second precision. If a `TimeZone` is a fixed
+    /// offset and has fractional minutes, then they will be expressed in the
+    /// `[+-]HH:MM:SS` format. Otherwise, the `:SS` will be omitted.
+    ///
+    /// This differs from RFC 3339 and RFC 9557 because neither support
+    /// sub-minute resolution in UTC offsets. Indeed, if one were to format
+    /// a `Zoned` with an offset that contains fractional minutes, the offset
+    /// would be rounded to the nearest minute to preserve compatibility with
+    /// RFC 3339 and RFC 9557. However, this routine does no such rounding.
+    /// This is because there is no RFC standardizing the serialization of
+    /// a lone time zone, and there is otherwise no need to reduce an offset's
+    /// precision.
+    ///
+    /// # Errors
+    ///
+    /// In some rare cases, serialization may fail when there is no succinct
+    /// representation of a time zone. One specific case in which this
+    /// occurs is when `TimeZone` is a user's system time zone derived from
+    /// `/etc/localtime`, but where an IANA time zone identifier could not
+    /// be found. This can occur, for example, when `/etc/localtime` is not
+    /// symlinked to an entry in `/usr/share/zoneinfo`.
+    ///
+    /// An error can also occur when writing to the given [`Write`]
+    /// implementation would fail. Some such implementations, like for `String`
+    /// and `Vec<u8>`, never fail (unless memory allocation fails).
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use jiff::{fmt::temporal::DateTimePrinter, tz::{self, TimeZone}};
+    ///
+    /// const PRINTER: DateTimePrinter = DateTimePrinter::new();
+    ///
+    /// // IANA time zone
+    /// let tz = TimeZone::get("US/Eastern")?;
+    /// let mut buf = String::new();
+    /// PRINTER.print_time_zone(&tz, &mut buf)?;
+    /// assert_eq!(buf, "US/Eastern");
+    ///
+    /// // Fixed offset
+    /// let tz = TimeZone::fixed(tz::offset(-5));
+    /// let mut buf = String::new();
+    /// PRINTER.print_time_zone(&tz, &mut buf)?;
+    /// assert_eq!(buf, "-05:00");
+    ///
+    /// // POSIX time zone
+    /// let tz = TimeZone::posix("EST5EDT,M3.2.0,M11.1.0")?;
+    /// let mut buf = String::new();
+    /// PRINTER.print_time_zone(&tz, &mut buf)?;
+    /// assert_eq!(buf, "EST5EDT,M3.2.0,M11.1.0");
+    ///
+    /// // The error case for a time zone that doesn't fall
+    /// // into one of the three categories about is not easy
+    /// // to create artificially. The only way, at time of
+    /// // writing, to produce it is via `TimeZone::system()`
+    /// // with a non-symlinked `/etc/timezone`. (Or `TZ` set
+    /// // to the path of a similar file.)
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    pub fn print_time_zone<W: Write>(
+        &self,
+        tz: &TimeZone,
+        wtr: W,
+    ) -> Result<(), Error> {
+        self.p.print_time_zone(tz, wtr)
+    }
+
     /// Print the `Pieces` of a Temporal datetime.
     ///
     /// # Errors
diff --git a/src/fmt/temporal/parser.rs b/src/fmt/temporal/parser.rs
index e10cffa..7f3bad9 100644
--- a/src/fmt/temporal/parser.rs
+++ b/src/fmt/temporal/parser.rs
@@ -12,7 +12,10 @@ use crate::{
         Parsed,
     },
     span::Span,
-    tz::{AmbiguousZoned, Disambiguation, OffsetConflict, TimeZoneDatabase},
+    tz::{
+        AmbiguousZoned, Disambiguation, OffsetConflict, TimeZone,
+        TimeZoneDatabase,
+    },
     util::{escape, parse, t},
     SignedDuration, Timestamp, Unit, Zoned,
 };
@@ -195,6 +198,63 @@ impl<'i> core::fmt::Display for ParsedTime<'i> {
     }
 }
 
+#[derive(Debug)]
+pub(super) struct ParsedTimeZone<'i> {
+    /// The original input that the time zone was parsed from.
+    input: escape::Bytes<'i>,
+    /// The kind of time zone parsed.
+    kind: ParsedTimeZoneKind<'i>,
+}
+
+impl<'i> core::fmt::Display for ParsedTimeZone<'i> {
+    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
+        core::fmt::Display::fmt(&self.input, f)
+    }
+}
+
+#[derive(Debug)]
+pub(super) enum ParsedTimeZoneKind<'i> {
+    Named(&'i str),
+    Offset(ParsedOffset),
+    #[cfg(feature = "alloc")]
+    Posix(crate::tz::posix::ReasonablePosixTimeZone),
+}
+
+impl<'i> ParsedTimeZone<'i> {
+    pub(super) fn into_time_zone(
+        self,
+        db: &TimeZoneDatabase,
+    ) -> Result<TimeZone, Error> {
+        match self.kind {
+            ParsedTimeZoneKind::Named(iana_name) => {
+                let tz = db.get(iana_name).with_context(|| {
+                    err!(
+                        "parsed apparent IANA time zone identifier \
+                         {iana_name} from {input}, but the tzdb lookup \
+                         failed",
+                        input = self.input,
+                    )
+                })?;
+                Ok(tz)
+            }
+            ParsedTimeZoneKind::Offset(poff) => {
+                let offset = poff.to_offset().with_context(|| {
+                    err!(
+                        "offset successfully parsed from {input}, \
+                         but failed to convert to numeric `Offset`",
+                        input = self.input,
+                    )
+                })?;
+                Ok(TimeZone::fixed(offset))
+            }
+            #[cfg(feature = "alloc")]
+            ParsedTimeZoneKind::Posix(posix_tz) => {
+                Ok(TimeZone::from_reasonable_posix_tz(posix_tz))
+            }
+        }
+    }
+}
+
 /// A parser for Temporal datetimes.
 #[derive(Debug)]
 pub(super) struct DateTimeParser {
@@ -366,6 +426,91 @@ impl DateTimeParser {
         Ok(Parsed { value: time, input })
     }
 
+    #[inline(always)]
+    pub(super) fn parse_time_zone<'i>(
+        &self,
+        mut input: &'i [u8],
+    ) -> Result<Parsed<'i, ParsedTimeZone<'i>>, Error> {
+        let Some(first) = input.first().copied() else {
+            return Err(err!("an empty string is not a valid time zone"));
+        };
+        let original = escape::Bytes(input);
+        if matches!(first, b'+' | b'-') {
+            static P: offset::Parser = offset::Parser::new()
+                .zulu(false)
+                .subminute(true)
+                .subsecond(false);
+            let Parsed { value: offset, input } = P.parse(input)?;
+            let kind = ParsedTimeZoneKind::Offset(offset);
+            let value = ParsedTimeZone { input: original, kind };
+            return Ok(Parsed { value, input });
+        }
+
+        // Creates a "named" parsed time zone, generally meant to
+        // be an IANA time zone identifier. We do this in a couple
+        // different cases below, hence the helper function.
+        let mknamed = |consumed, remaining| {
+            let Ok(tzid) = core::str::from_utf8(consumed) else {
+                return Err(err!(
+                    "found plausible IANA time zone identifier \
+                     {input:?}, but it is not valid UTF-8",
+                    input = escape::Bytes(consumed),
+                ));
+            };
+            let kind = ParsedTimeZoneKind::Named(tzid);
+            let value = ParsedTimeZone { input: original, kind };
+            Ok(Parsed { value, input: remaining })
+        };
+        // This part get tricky. The common case is absolutely an IANA time
+        // zone identifer. So we try to parse something that looks like an IANA
+        // tz id.
+        //
+        // In theory, IANA tz ids can never be valid POSIX TZ strings, since
+        // POSIX TZ strings minimally require an offset in them (e.g., `EST5`)
+        // and IANA tz ids aren't supposed to contain numbers. But there are
+        // some legacy IANA tz ids (`EST5EDT`) that do contain numbers.
+        //
+        // However, the legacy IANA tz ids, like `EST5EDT`, are pretty much
+        // nonsense as POSIX TZ strings since there is no DST transition rule.
+        // So in cases of nonsense tz ids, we assume they are IANA tz ids.
+        let mkconsumed = parse::slicer(input);
+        let mut saw_number = false;
+        loop {
+            let Some(byte) = input.first().copied() else { break };
+            if byte.is_ascii_whitespace() {
+                break;
+            }
+            saw_number = saw_number || byte.is_ascii_digit();
+            input = &input[1..];
+        }
+        let consumed = mkconsumed(input);
+        if !saw_number {
+            return mknamed(consumed, input);
+        }
+        #[cfg(not(feature = "alloc"))]
+        {
+            Err(err!(
+                "cannot parsed time zones other than fixed offsets \
+                 without the `alloc` crate feature enabled",
+            ))
+        }
+        #[cfg(feature = "alloc")]
+        {
+            match crate::tz::posix::IanaTz::parse_v3plus_prefix(consumed) {
+                Ok((iana_tz, input)) => {
+                    let kind = ParsedTimeZoneKind::Posix(iana_tz.into_tz());
+                    let value = ParsedTimeZone { input: original, kind };
+                    Ok(Parsed { value, input })
+                }
+                // We get here for invalid POSIX tz strings, or even if they
+                // are valid but not "reasonable", i.e., `EST5EDT`. Which in
+                // that case would end up doing an IANA tz lookup. (And it
+                // might hit because `EST5EDT` is a legacy IANA tz id. Lol.)
+                Err(_) => mknamed(consumed, input),
+            }
+        }
+    }
+
     // Date :::
     //   DateYear - DateMonth - DateDay
     //   DateYear DateMonth DateDay
diff --git a/src/fmt/temporal/printer.rs b/src/fmt/temporal/printer.rs
index 7458967..16ca57d 100644
--- a/src/fmt/temporal/printer.rs
+++ b/src/fmt/temporal/printer.rs
@@ -1,6 +1,6 @@
 use crate::{
     civil::{Date, DateTime, Time},
-    error::Error,
+    error::{err, Error},
     fmt::{
         temporal::{Pieces, PiecesOffset, TimeZoneAnnotationKind},
         util::{DecimalFormatter, FractionalFormatter},
@@ -56,8 +56,8 @@ impl DateTimePrinter {
         let (offset, _, _) = tz.to_offset(timestamp);
         let dt = offset.to_datetime(timestamp);
         self.print_datetime(&dt, &mut wtr)?;
-        self.print_offset(&offset, &mut wtr)?;
-        self.print_time_zone(&tz, &offset, &mut wtr)?;
+        self.print_offset_rounded(&offset, &mut wtr)?;
+        self.print_time_zone_annotation(&tz, &offset, &mut wtr)?;
         Ok(())
     }
 
@@ -75,7 +75,7 @@ impl DateTimePrinter {
         };
         let dt = offset.to_datetime(*timestamp);
         self.print_datetime(&dt, &mut wtr)?;
-        self.print_offset(&offset, &mut wtr)?;
+        self.print_offset_rounded(&offset, &mut wtr)?;
         Ok(())
     }
 
@@ -144,6 +144,55 @@ impl DateTimePrinter {
         Ok(())
     }
 
+    /// Formats the given time zone into the writer given.
+    pub(super) fn print_time_zone<W: Write>(
+        &self,
+        tz: &TimeZone,
+        mut wtr: W,
+    ) -> Result<(), Error> {
+        if let Some(iana_name) = tz.iana_name() {
+            return wtr.write_str(iana_name);
+        }
+        if let Ok(offset) = tz.to_fixed_offset() {
+            return self.print_offset_full_precision(&offset, wtr);
+        }
+        // `ReasonablePosixTimeZone` is currently only available when the
+        // `alloc` feature is enabled. (The type itself is compatible with
+        // core-only environments, but is effectively disabled because it
+        // greatly bloats the size of `TimeZone` and thus `Zoned` since there's
+        // no way to easily introduce indirection in core-only environments.)
+        #[cfg(feature = "alloc")]
+        {
+            if let Some(posix_tz) = tz.posix_tz() {
+                // This is pretty unfortunate, but at time of writing, I
+                // didn't see an easy way to make the `Display` impl for
+                // `ReasonablePosixTimeZone` automatically work with
+                // `jiff::fmt::Write` without allocating a new string. As
+                // far as I can see, I either have to duplicate the code or
+                // make it generic in some way. I judged neither to be worth
+                // doing for such a rare case. ---AG
+                let s = alloc::string::ToString::to_string(posix_tz);
+                return wtr.write_str(&s);
+            }
+        }
+        // Ideally this never actually happens, but it can, and there
+        // are likely system configurations out there in which it does.
+        // I can imagine "lightweight" installations that just have a
+        // `/etc/localtime` as a TZif file that doesn't point to any IANA time
+        // zone. In which case, serializing a time zone probably doesn't make
+        // much sense.
+        //
+        // Anyway, if you're seeing this error and think there should be a
+        // different behavior, please file an issue.
+        Err(err!(
+            "time zones without IANA identifiers that aren't either \
+             fixed offsets or a POSIX time zone can't be serialized \
+             (this typically occurs when this is a system time zone \
+              derived from `/etc/localtime` on Unix systems that \
+              isn't symlinked to an entry in `/usr/share/zoneinfo`)",
+        ))
+    }
+
     pub(super) fn print_pieces<W: Write>(
         &self,
         pieces: &Pieces,
@@ -185,7 +234,7 @@ impl DateTimePrinter {
                     wtr.write_str(name.as_str())?
                 }
                 TimeZoneAnnotationKind::Offset(offset) => {
-                    self.print_offset(&offset, &mut wtr)?
+                    self.print_offset_rounded(&offset, &mut wtr)?
                 }
             }
             wtr.write_str("]")?;
@@ -205,14 +254,17 @@ impl DateTimePrinter {
                 if noffset.offset().is_zero() && noffset.is_negative() {
                     wtr.write_str("-00:00")
                 } else {
-                    self.print_offset(&noffset.offset(), wtr)
+                    self.print_offset_rounded(&noffset.offset(), wtr)
                 }
             }
         }
     }
 
     /// Formats the given offset into the writer given.
-    fn print_offset<W: Write>(
+    ///
+    /// If the given offset has non-zero seconds, then they are rounded to
+    /// the nearest minute.
+    fn print_offset_rounded<W: Write>(
         &self,
         offset: &Offset,
         mut wtr: W,
@@ -242,6 +294,32 @@ impl DateTimePrinter {
         Ok(())
     }
 
+    /// Formats the given offset into the writer given.
+    ///
+    /// If the given offset has non-zero seconds, then they are emitted as a
+    /// third `:`-delimited component of the offset. If seconds are zero, then
+    /// only the hours and minute components are emitted.
+    fn print_offset_full_precision<W: Write>(
+        &self,
+        offset: &Offset,
+        mut wtr: W,
+    ) -> Result<(), Error> {
+        static FMT_TWO: DecimalFormatter = DecimalFormatter::new().padding(2);
+
+        wtr.write_str(if offset.is_negative() { "-" } else { "+" })?;
+        let hours = offset.part_hours_ranged().abs().get();
+        let minutes = offset.part_minutes_ranged().abs().get();
+        let seconds = offset.part_seconds_ranged().abs().get();
+        wtr.write_int(&FMT_TWO, hours)?;
+        wtr.write_str(":")?;
+        wtr.write_int(&FMT_TWO, minutes)?;
+        if seconds > 0 {
+            wtr.write_str(":")?;
+            wtr.write_int(&FMT_TWO, seconds)?;
+        }
+        Ok(())
+    }
+
     /// Prints the "zulu" indicator.
     ///
     /// This should only be used when the offset is not known. For example,
@@ -250,14 +328,15 @@ impl DateTimePrinter {
         wtr.write_str(if self.lowercase { "z" } else { "Z" })
     }
 
-    /// Formats the given time zone name into the writer given.
+    /// Formats the given time zone name into the writer given as an RFC 9557
+    /// time zone annotation.
     ///
     /// This is a no-op when RFC 9557 support isn't enabled. And when the given
     /// time zone is not an IANA time zone name, then the offset is printed
     /// instead. (This means the offset will be printed twice, which is indeed
     /// an intended behavior of RFC 9557 for cases where a time zone name is
     /// not used or unavailable.)
-    fn print_time_zone<W: Write>(
+    fn print_time_zone_annotation<W: Write>(
         &self,
         time_zone: &TimeZone,
         offset: &Offset,
@@ -270,7 +349,7 @@ impl DateTimePrinter {
         if let Some(iana_name) = time_zone.iana_name() {
             wtr.write_str(iana_name)?;
         } else {
-            self.print_offset(offset, &mut wtr)?;
+            self.print_offset_rounded(offset, &mut wtr)?;
         }
         wtr.write_str("]")?;
         Ok(())
diff --git a/src/tz/mod.rs b/src/tz/mod.rs
index be07904..3b09485 100644
--- a/src/tz/mod.rs
+++ b/src/tz/mod.rs
@@ -105,7 +105,7 @@ mod concatenated;
 mod db;
 mod offset;
 #[cfg(feature = "alloc")]
-mod posix;
+pub(crate) mod posix;
 #[cfg(feature = "tz-system")]
 mod system;
 #[cfg(all(test, feature = "alloc"))]
@@ -296,12 +296,50 @@ mod zic;
 /// # Ok::<(), Box<dyn std::error::Error>>(())
 /// ```
 ///
+/// # Serde integration
+///
+/// At present, a `TimeZone` does not implement Serde's `Serialize` or
+/// `Deserialize` traits directly. Nor does it implement `std::fmt::Display`
+/// or `std::str::FromStr`. The reason for this is that it's not totally
+/// clear if there is one single obvious behavior. Moreover, some `TimeZone`
+/// values do not have an obvious succinct serialized representation. (For
+/// example, when `/etc/localtime` on a Unix system is your system's time zone,
+/// and it isn't a symlink to a TZif file in `/usr/share/zoneinfo`. In which
+/// case, an IANA time zone identifier cannot easily be deduced by Jiff.)
+///
+/// Instead, Jiff offers helpers for use with Serde's [`with` attribute] via
+/// the [`fmt::serde`](crate::fmt::serde) module:
+///
+/// ```
+/// use jiff::tz::TimeZone;
+///
+/// #[derive(Debug, serde::Deserialize, serde::Serialize)]
+/// struct Record {
+///     #[serde(with = "jiff::fmt::serde::tz::optional")]
+///     tz: Option<TimeZone>,
+/// }
+///
+/// let json = r#"{"tz":"America/Nuuk"}"#;
+/// let got: Record = serde_json::from_str(&json)?;
+/// assert_eq!(got.tz, Some(TimeZone::get("America/Nuuk")?));
+/// assert_eq!(serde_json::to_string(&got)?, json);
+///
+/// # Ok::<(), Box<dyn std::error::Error>>(())
+/// ```
+///
+/// Alternatively, you may use the
+/// [`fmt::temporal::DateTimeParser::parse_time_zone`](crate::fmt::temporal::DateTimeParser::parse_time_zone)
+/// or
+/// [`fmt::temporal::DateTimePrinter::print_time_zone`](crate::fmt::temporal::DateTimePrinter::print_time_zone)
+/// routines to parse or print `TimeZone` values without using Serde.
+///
 /// [time zone]: https://en.wikipedia.org/wiki/Time_zone
 /// [daylight saving time]: https://en.wikipedia.org/wiki/Daylight_saving_time
 /// [IANA Time Zone Database]: https://en.wikipedia.org/wiki/Tz_database
 /// [POSIX TZ]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html
 /// [`env_logger`]: https://docs.rs/env_logger
 /// [RFC 8536]: https://datatracker.ietf.org/doc/html/rfc8536
+/// [`with` attribute]: https://serde.rs/field-attrs.html#with
 #[derive(Clone, Eq, PartialEq)]
 pub struct TimeZone {
     kind: Option<Arc<TimeZoneKind>>,
@@ -539,9 +577,20 @@ impl TimeZone {
     /// ```
     #[cfg(feature = "alloc")]
     pub fn posix(posix_tz_string: &str) -> Result<TimeZone, Error> {
-        let posix = TimeZonePosix::new(posix_tz_string)?;
+        let iana_tz = posix::IanaTz::parse_v3plus(posix_tz_string)?;
+        let reasonable = iana_tz.into_tz();
+        Ok(TimeZone::from_reasonable_posix_tz(reasonable))
+    }
+
+    /// Creates a time zone from a POSIX tz. Expose so that other parts of Jiff
+    /// can create a `TimeZone` from a POSIX tz. (Kinda sloppy to be honest.)
+    #[cfg(feature = "alloc")]
+    pub(crate) fn from_reasonable_posix_tz(
+        posix: ReasonablePosixTimeZone,
+    ) -> TimeZone {
+        let posix = TimeZonePosix { posix };
         let kind = TimeZoneKind::Posix(posix);
-        Ok(TimeZone { kind: Some(Arc::new(kind)) })
+        TimeZone { kind: Some(Arc::new(kind)) }
     }
 
     /// Creates a time zone from TZif binary data, whose format is specified
@@ -611,6 +660,25 @@ impl TimeZone {
         DiagnosticName(self)
     }
 
+    /// Returns true if and only if this `TimeZone` can be succinctly
+    /// serialized.
+    ///
+    /// Basically, this is only `false` when this `TimeZone` was created from
+    /// a `/etc/localtime` for which a valid IANA time zone identifier could
+    /// not be extracted.
+    #[cfg(feature = "serde")]
+    #[inline]
+    pub(crate) fn has_succinct_serialization(&self) -> bool {
+        let Some(ref kind) = self.kind else { return true };
+        match **kind {
+            TimeZoneKind::Fixed(_) => true,
+            #[cfg(feature = "alloc")]
+            TimeZoneKind::Posix(_) => true,
+            #[cfg(feature = "alloc")]
+            TimeZoneKind::Tzif(ref tz) => tz.name().is_some(),
+        }
+    }
+
     /// When this time zone was loaded from an IANA time zone database entry,
     /// then this returns the canonicalized name for that time zone.
     ///
@@ -634,6 +702,23 @@ impl TimeZone {
         }
     }
 
+    /// When this time zone is a POSIX time zone, return it.
+    ///
+    /// This doesn't attempt to convert other time zones that are representable
+    /// as POSIX time zones to POSIX time zones (e.g., fixed offset time
+    /// zones). Instead, this only returns something when the actual
+    /// representation of the time zone is a POSIX time zone.
+    #[cfg(feature = "alloc")]
+    #[inline]
+    pub(crate) fn posix_tz(&self) -> Option<&ReasonablePosixTimeZone> {
+        let Some(ref kind) = self.kind else { return None };
+        match **kind {
+            #[cfg(feature = "alloc")]
+            TimeZoneKind::Posix(ref tz) => Some(&tz.posix),
+            _ => None,
+        }
+    }
+
     /// Returns the civil datetime corresponding to the given timestamp in this
     /// time zone.
     ///
@@ -1319,12 +1404,6 @@ struct TimeZonePosix {
 
 #[cfg(feature = "alloc")]
 impl TimeZonePosix {
-    #[inline]
-    fn new(s: &str) -> Result<TimeZonePosix, Error> {
-        let iana_tz = posix::IanaTz::parse_v3plus(s)?;
-        Ok(TimeZonePosix::from(iana_tz.into_tz()))
-    }
-
     #[inline]
     fn to_offset(&self, timestamp: Timestamp) -> (Offset, Dst, &str) {
         self.posix.to_offset(timestamp)
@@ -1352,14 +1431,6 @@ impl TimeZonePosix {
     }
 }
 
-#[cfg(feature = "alloc")]
-impl From<ReasonablePosixTimeZone> for TimeZonePosix {
-    #[inline]
-    fn from(posix: ReasonablePosixTimeZone) -> TimeZonePosix {
-        TimeZonePosix { posix }
-    }
-}
-
 // This is implemented by hand because dumping out the full representation of
 // a `ReasonablePosixTimeZone` is way too much noise for users of Jiff.
 #[cfg(feature = "alloc")]
diff --git a/src/tz/posix.rs b/src/tz/posix.rs
index 7558bbc..d5f85ae 100644
--- a/src/tz/posix.rs
+++ b/src/tz/posix.rs
@@ -193,6 +193,25 @@ impl IanaTz {
         Ok(IanaTz(reasonable))
     }
 
+    /// Like `parse_v3plus`, but parses a POSIX TZ string from a prefix of the
+    /// given input. And remaining input is returned.
+    pub(crate) fn parse_v3plus_prefix<'b, B: AsRef<[u8]> + ?Sized + 'b>(
+        bytes: &'b B,
+    ) -> Result<(IanaTz, &'b [u8]), Error> {
+        let bytes = bytes.as_ref();
+        let (posix_tz, remaining) = PosixTimeZone::parse_prefix(bytes)
+            .map_err(|e| {
+                e.context(err!("invalid POSIX TZ string {:?}", Bytes(bytes)))
+            })?;
+        let Ok(reasonable) = posix_tz.reasonable() else {
+            return Err(err!(
+                "TZ string {:?} in v3+ tzfile has DST but no transition rules",
+                Bytes(bytes),
+            ));
+        };
+        Ok((IanaTz(reasonable), remaining))
+    }
+
     /// Return ownership of the underlying "reasonable" POSIX time zone value.
     ///
     /// If this was parsed as an IANA v3+ `TZ` string, then the DST transition
@@ -584,6 +603,16 @@ impl PosixTimeZone {
         parser.parse()
     }
 
+    /// Like parse, but parses a prefix of the input given and returns whatever
+    /// is remaining.
+    fn parse_prefix<'b, B: AsRef<[u8]> + ?Sized + 'b>(
+        bytes: &'b B,
+    ) -> Result<(PosixTimeZone, &'b [u8]), Error> {
+        let parser =
+            Parser { ianav3plus: true, ..Parser::new(bytes.as_ref()) };
+        parser.parse_prefix()
+    }
+
     /// Transforms this POSIX time zone into a "reasonable" time zone.
     ///
     /// If this isn't a reasonable time zone, then the original time zone is
@@ -1037,18 +1066,25 @@ impl<'s> Parser<'s> {
     /// ensures that the entire TZ string corresponds to a single valid POSIX
     /// time zone.
     fn parse(&self) -> Result<PosixTimeZone, Error> {
-        let time_zone = self.parse_posix_time_zone()?;
-        if !self.remaining().is_empty() {
+        let (time_zone, remaining) = self.parse_prefix()?;
+        if !remaining.is_empty() {
             return Err(err!(
                 "expected entire TZ string to be a valid POSIX \
                  time zone, but found '{}' after what would otherwise \
                  be a valid POSIX TZ string",
-                Bytes(self.remaining()),
+                Bytes(remaining),
             ));
         }
         Ok(time_zone)
     }
 
+    /// Parses a POSIX time zone from the current position of the parser and
+    /// returns the remaining input.
+    fn parse_prefix(&self) -> Result<(PosixTimeZone, &'s [u8]), Error> {
+        let time_zone = self.parse_posix_time_zone()?;
+        Ok((time_zone, self.remaining()))
+    }
+
     /// Parse a POSIX time zone from the current position of the parser.
     ///
     /// Upon success, the parser will be positioned immediately following the
@@ -1753,7 +1789,7 @@ impl<'s> Parser<'s> {
     /// Returns the remaining bytes of the TZ string.
     ///
     /// This includes `self.byte()`. It may be empty.
-    fn remaining(&self) -> &[u8] {
+    fn remaining(&self) -> &'s [u8] {
         &self.tz[self.pos()..]
     }
 }
diff --git a/src/tz/system/mod.rs b/src/tz/system/mod.rs
index 4643b06..2a25364 100644
--- a/src/tz/system/mod.rs
+++ b/src/tz/system/mod.rs
@@ -5,7 +5,7 @@ use alloc::string::ToString;
 use crate::{
     error::{err, Error, ErrorContext},
     tz::{posix::PosixTz, TimeZone, TimeZoneDatabase},
-    util::{cache::Expiration, sync::Arc},
+    util::cache::Expiration,
 };
 
 #[cfg(all(unix, not(target_os = "android")))]
@@ -197,11 +197,9 @@ fn get_env_tz(db: &TimeZoneDatabase) -> Result<Option<TimeZone>, Error> {
         Ok(PosixTz::Implementation(string)) => string.to_string(),
         Ok(PosixTz::Rule(tz)) => match tz.reasonable() {
             Ok(reasonable_posix_tz) => {
-                let kind = super::TimeZoneKind::Posix(
-                    super::TimeZonePosix::from(reasonable_posix_tz),
-                );
-                let tz = TimeZone { kind: Some(Arc::new(kind)) };
-                return Ok(Some(tz));
+                return Ok(Some(TimeZone::from_reasonable_posix_tz(
+                    reasonable_posix_tz,
+                )));
             }
             Err(_) => {
                 warn!(
diff --git a/src/util/escape.rs b/src/util/escape.rs
index 4528f4b..2c49873 100644
--- a/src/util/escape.rs
+++ b/src/util/escape.rs
@@ -48,6 +48,7 @@ impl core::fmt::Debug for Byte {
 /// This generally works best when the bytes are presumed to be mostly UTF-8,
 /// but will work for anything. For any bytes that aren't UTF-8, they are
 /// emitted as hex escape sequences.
+#[derive(Clone, Copy)]
 pub struct Bytes<'a>(pub &'a [u8]);
 
 impl<'a> core::fmt::Display for Bytes<'a> {


Test patch: