Repository: aws/aws-sdk-pandas

Issue:
timestamps on athena.read_sql_query wrap around to give wrong value in pandas
**Describe the bug**

When reading timestamp values that are out of bounds for `Timestamp[ns]` the values just get wrapped around to different timestamps (as far as I can tell). This only seems to be a problem for when you set `ctas_approach=True` in the `athena.read_sql_query` function. If you set this to False it is read in correctly. So I guess there is a mismatch between how Athena writes the data to parquet and wrangler reads in the parquet file?

**To Reproduce**

Soz this is fairly long as tried to make something reproducible. If you already have tables that have columns with out of bounds timestamps (for ns) then you can skip to my `# post-setup` comment in the code.

```python
# Create a table with timestamps that are out of bounds Timestamp[ns]
import awswrangler as wr
import pandas as pd
import datetime

i = pd.Series([0,1,2])
d = pd.Series([datetime.date(1000, 1, 1), datetime.date(2020,1,1), datetime.date(3000, 1, 1)])
dt = pd.Series(["1000-01-01 00:00:00", "2020-01-01 00:00:00", "3000-01-01 00:00:00"])
df = pd.DataFrame({"i": i, "d":d, "dt": dt})

# Create the database
database = "isichei_test"
wr.catalog.create_database(database)
table_path = "<Wherever you wanna throw your data>" 
wr.s3.to_parquet(
    df=df,
    path=table_path,
    index=False,
    dataset=True,
    database=database,
    table='test_table',
    mode="overwrite",
    dtype={"i": "int", "d": "date", "dt": "string"} # cannot specify timestamp as wrangler errors out here (due to same pandas TS casting issues)
)

# post-setup
## If you have tables in parquet that are stored as Timestamp[s] then you can just query them (i.e. SELECT *) - this cast in the SQL query is just for this example. In reality we discovered this on querying one of our tables that are written to parquet using Spark.

df_ctas = wr.athena.read_sql_query("SELECT *, cast(dt as timestamp) AS dt_timestamp FROM isichei_test.test_table", database="isichei_test")

df_ctas.dt_timestamp
# returns: 2169-02-08 23:09:07.419103232, 2020-01-01 00:00:00.000000000, 1830-11-23 00:50:52.580896768
```

### Some further notes

In the example above if you set `ctas_approach = False` you will get an Pandas error due to trying to apply dt accessor in something that isn't a dt. However, on our tables where the data is written to parquet via other means (spark or pyarrow) and you just query those values with `ctas_approach=False` you do not get the timestamp wrapping error. If you need it, I can try and create a reproducible error that lets me write parquet files to S3 using arrow (or something) but this is hopefully enough?

For our etl pipelines we now use Arrow to read in data and then try to strictly convert the timestamps to pandas object arrays to avoid `Timestamp[ns]` issues. I don't know if you have an option in this function or package to do the same (/ point me in the direction to PR it)? I am not 100% sure if this is actually a pandas read in issue rather than something like a weird mismatch in the parquet metadata between the Athena engine and wrangler. Finally, dates are unaffected the resulting output for the `d` column was correct.

Also worth noting we are still using the Athena v1 engine.

env:
```
awswrangler         2.3.0
pandas              1.2.0
pyarrow             2.0.0
```




Discussion:
Thank you for the detailed steps @isichei, it helped reproduce the problem. However, this is an arrow library issue.

The CTAS Athena query is dumping a parquet S3 object with the results. The object is then read as an [arrow parquet file](https://github.com/awslabs/aws-data-wrangler/blob/cf99eafb7b0f6a0a46a1c29357e1ed5d5a5a4680/awswrangler/s3/_read_parquet.py#L371) by the `wr.s3.read_parquet` method and converted to an Arrow table where the `dt_timestamp` column is inferred to be of Arrow type `timestamp[ns]`. But the dates you submitted are considered out of range timestamps in Arrow 2.0.0 and it returns those wrong values at the edge instead. From my little research, anything inside this range more or less:
```
dt = pd.Series(["1677-09-22 00:00:00", "2020-01-01 00:00:00", "2262-04-11 00:00:00"])
``` 
is fine, but anything outside and Arrow returns weird timestamps.

Here are some additional links on the same issue:
- https://issues.apache.org/jira/browse/ARROW-6779
- https://github.com/awslabs/aws-data-wrangler/issues/296

In regards to why you are not encountering the error when `ctas_approach=False`, that is because in that case the result of the Athena query is a [CSV file that we then read directly from S3](https://github.com/awslabs/aws-data-wrangler/blob/cf99eafb7b0f6a0a46a1c29357e1ed5d5a5a4680/awswrangler/athena/_read.py#L489) without going through Arrow.
Hi @jaidisido,

Thanks for replying, that makes sense. I was wondering if this means that you are going to wait for the issue to be fixed on pyarrow side? Otherwise the "fix" on your side would be setting `timestamp_as_object=True` in this [function call](https://github.com/awslabs/aws-data-wrangler/blob/fc77f1dad44a1719499306412560a7363c7604ac/awswrangler/s3/_read_parquet.py#L251-L261). This then avoids the pandas timestamp[ns] issue.

Another option (to ensure backwards compatability) is to allow users to pass kwargs to this function call when using the athena module (or by adding the option to set this in the configs for `awswrangler`). If you think it is worth trying and have a preferred method happy to give it a go myself on a PR.



The issue is that the timestamps get messed by pyarrow before they reach that `_arrowtable2df` method. As I mentioned the parquet file is first [read](https://github.com/awslabs/aws-data-wrangler/blob/cf99eafb7b0f6a0a46a1c29357e1ed5d5a5a4680/awswrangler/s3/_read_parquet.py#L376) ([docs](https://arrow.apache.org/docs/python/generated/pyarrow.parquet.ParquetFile.html#pyarrow.parquet.ParquetFile.read)) into a pyarrow table. At this point, the timestamps are already converted and wrong. Enabling `timestamp_as_object=True` on the subsequent call would not help at that point. Not sure how to circumvent that...
Apologies @jaidisido you're right. Looks like the issue is specifically how `pyarrow` deals with Parquet INT96 types. Athena writes parquet data using the version 1.0 of parquet which uses INT96 types for timestamps (deprecated in later Parquet versions ([source](https://arrow.apache.org/docs/python/parquet.html#storing-timestamps))).

 When arrow reads the parquet file with a physical type of INT96 it will always infer that this should be a `timestamp[ns]` arrow type and then the wrap around occurs on conversion to the arrow table this will still happen without error for pyarrow v2 and v3. Interestingly the example in [ARROW-6779](https://issues.apache.org/jira/browse/ARROW-6779) now throws an out of bounds error if ran in pyarrow v3.0.0.

As far as I can tell the conversion from parquet type to arrow type is in the Cython code for the Parquet reader in the pyarrow library and the conversion is not exposed to the python APIs in pyarrow so you can't set it yourself. I've been able to create a round trip example just using pyarrow so will raise a ticket with them when I get the chance. 

Thanks for your help!
Thank you for the clear explanation @isichei, please keep us posted on the outcome of the Arrow issue. Closing for now but feel free to reopen in the future
For reference: https://issues.apache.org/jira/browse/ARROW-12096
@jaidisido can we re-open now?

I have applied a fix / workaround for PyArrow (in v5 release)
Thanks @isichei, latest version of Wrangler (2.11.0) has a [dependency](https://github.com/awslabs/aws-data-wrangler/blob/main/pyproject.toml#L39) on Pyarrow 5. As long as your fix is included in there, you could test it using the latest version.
Hi,

i've a `pandas `dataframe with i.e. a column of type:
`ANDATNAS         datetime64[ns]`

and I wrote the dataframe as parquet, using both the `pandas` and the `awswrangler` methods:

```
df.to_parquet(
     path='my_path',
     engine='pyarrow',
     partition_cols=['table_version', 'version'],
     compression='snappy'
     )
wr.s3.to_parquet(
    df=df,
    path='my_path',
    boto3_session=s3_session,
    dataset=True,
    partition_cols=['table_version', 'version']
)
```
with these versions:
```
pyarrow==5.0.0
pandas==1.2.4
awswrangler==2.11.0
```
Then, i've ispected the parquet content with `parquet-tools`.
In the parquet written by `pandas`:
```
############ Column(ANDATNAS) ############
name: ANDATNAS
path: ANDATNAS
max_definition_level: 1
max_repetition_level: 0
physical_type: INT64
logical_type: Timestamp(isAdjustedToUTC=false, timeUnit=microseconds, is_from_converted_type=false, force_set_converted_type=false)
converted_type (legacy): NONE
```
while in the parquet written by `awswrangler`:
```
############ Column(ANDATNAS) ############
name: ANDATNAS
path: ANDATNAS
max_definition_level: 1
max_repetition_level: 0
physical_type: INT96
logical_type: None
converted_type (legacy): NONE
```
I can't manage the `INT96` type for dates exceeding limits (i.e. 1900) with tools like Athena, Presto or Trino:
`Invalid timeOfDayNanos: -82800000000000
`
I think the `INT96 ` type is deprecated and I suppose the problem is not in the `pyarrow` library (I'm using the same version in both `pandas` and `awsgrangler`) but in the way `awswrangler` is using it.

Any suggestion to write the parquet file in the right format also with `awswrangler`?

Thanks!








Hi @jaidisido I might have time to try and apply a fix next week
@z-arosio Checking, not entirely sure yet where the difference comes from...

UPD: Looks like `INT96` is the result of using [spark flavor of parquet](https://github.com/awslabs/aws-data-wrangler/blob/cf99eafb7b0f6a0a46a1c29357e1ed5d5a5a4680/awswrangler/s3/_write_parquet.py#L79). It is possible to disable it by passing `use_deprecated_int96_timestamps=False`
@kukushking Thank you very much!
How could I pass the `use_deprecated_int96_timestamps` parameter to the [awswrangler.s3.to_parquet](https://aws-data-wrangler.readthedocs.io/en/stable/stubs/awswrangler.s3.to_parquet.html) method?
@z-arosio I have sent a PR above, once merged you can install development version via:
```
pip uninstall awswrangler -y
pip install git+https://github.com/awslabs/aws-data-wrangler.git@main
```
And pass via `wr.s3.to_parquet(..., pyarrow_additional_kwargs={"use_deprecated_int96_timestamps"=False})`
Is it planned to merge the PR #917? This won't fix this specific issue but the same approach needs to be taken.

1. Allow the parquet read function to take `pyarrow` specific arguments
2. Then use `coerce_int96_timestamp_unit` in the pyarrow reader to get around Athena using the deprecated INT96 format.

If #917 is going to be merged then I'll add the args to the reader using the same conventions as #917 to be consistent
@isichei Yep, #917 is merged. We can follow the same approach for `read_parquet` - happy to accept the contribution!

Resolving patch:
diff --git a/awswrangler/_data_types.py b/awswrangler/_data_types.py
--- a/awswrangler/_data_types.py
+++ b/awswrangler/_data_types.py
@@ -701,3 +701,10 @@ def timestream_type_from_pandas(df: pd.DataFrame) -> str:
     pyarrow_type: pa.DataType = list(pyarrow_types.values())[0]
     _logger.debug("pyarrow_type: %s", pyarrow_type)
     return pyarrow2timestream(dtype=pyarrow_type)
+
+
+def get_arrow_timestamp_unit(data_type: pa.lib.DataType) -> Any:
+    """Return unit of pyarrow timestamp. If the pyarrow type is not timestamp then None is returned."""
+    if isinstance(data_type, pa.lib.TimestampType):
+        return data_type.unit
+    return None
diff --git a/awswrangler/athena/_read.py b/awswrangler/athena/_read.py
--- a/awswrangler/athena/_read.py
+++ b/awswrangler/athena/_read.py
@@ -222,6 +222,7 @@ def _fetch_parquet_result(
     boto3_session: boto3.Session,
     s3_additional_kwargs: Optional[Dict[str, Any]],
     temp_table_fqn: Optional[str] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Union[pd.DataFrame, Iterator[pd.DataFrame]]:
     ret: Union[pd.DataFrame, Iterator[pd.DataFrame]]
     chunked: Union[bool, int] = False if chunksize is None else chunksize
@@ -249,6 +250,7 @@ def _fetch_parquet_result(
         chunked=chunked,
         categories=categories,
         ignore_index=True,
+        pyarrow_additional_kwargs=pyarrow_additional_kwargs,
     )
     if chunked is False:
         ret = _apply_query_metadata(df=ret, query_metadata=query_metadata)
@@ -337,6 +339,7 @@ def _resolve_query_with_cache(
     use_threads: bool,
     session: Optional[boto3.Session],
     s3_additional_kwargs: Optional[Dict[str, Any]],
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Union[pd.DataFrame, Iterator[pd.DataFrame]]:
     """Fetch cached data and return it as a pandas DataFrame (or list of DataFrames)."""
     _logger.debug("cache_info:\n%s", cache_info)
@@ -358,6 +361,7 @@ def _resolve_query_with_cache(
             use_threads=use_threads,
             boto3_session=session,
             s3_additional_kwargs=s3_additional_kwargs,
+            pyarrow_additional_kwargs=pyarrow_additional_kwargs,
         )
     if cache_info.file_format == "csv":
         return _fetch_csv_result(
@@ -389,6 +393,7 @@ def _resolve_query_without_cache_ctas(
     use_threads: bool,
     s3_additional_kwargs: Optional[Dict[str, Any]],
     boto3_session: boto3.Session,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Union[pd.DataFrame, Iterator[pd.DataFrame]]:
     path: str = f"{s3_output}/{name}"
     ext_location: str = "\n" if wg_config.enforced is True else f",\n    external_location = '{path}'\n"
@@ -465,6 +470,7 @@ def _resolve_query_without_cache_ctas(
         s3_additional_kwargs=s3_additional_kwargs,
         boto3_session=boto3_session,
         temp_table_fqn=fully_qualified_name,
+        pyarrow_additional_kwargs=pyarrow_additional_kwargs,
     )
 
 
@@ -532,6 +538,7 @@ def _resolve_query_without_cache(
     use_threads: bool,
     s3_additional_kwargs: Optional[Dict[str, Any]],
     boto3_session: boto3.Session,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Union[pd.DataFrame, Iterator[pd.DataFrame]]:
     """
     Execute a query in Athena and returns results as DataFrame, back to `read_sql_query`.
@@ -565,6 +572,7 @@ def _resolve_query_without_cache(
                 use_threads=use_threads,
                 s3_additional_kwargs=s3_additional_kwargs,
                 boto3_session=boto3_session,
+                pyarrow_additional_kwargs=pyarrow_additional_kwargs,
             )
         finally:
             catalog.delete_table_if_exists(
@@ -612,6 +620,7 @@ def read_sql_query(
     data_source: Optional[str] = None,
     params: Optional[Dict[str, Any]] = None,
     s3_additional_kwargs: Optional[Dict[str, Any]] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Union[pd.DataFrame, Iterator[pd.DataFrame]]:
     """Execute any SQL query on AWS Athena and return the results as a Pandas DataFrame.
 
@@ -781,6 +790,14 @@ def read_sql_query(
     s3_additional_kwargs : Optional[Dict[str, Any]]
         Forwarded to botocore requests.
         e.g. s3_additional_kwargs={'RequestPayer': 'requester'}
+    pyarrow_additional_kwargs : Optional[Dict[str, Any]]
+        Forward to the ParquetFile class or converting an Arrow table to Pandas, currently only an
+        "coerce_int96_timestamp_unit" or "timestamp_as_object" argument will be considered. If reading parquet
+        files where you cannot convert a timestamp to pandas Timestamp[ns] consider setting timestamp_as_object=True,
+        to allow for timestamp units larger than "ns". If reading parquet data that still uses INT96 (like Athena
+        outputs) you can use coerce_int96_timestamp_unit to specify what timestamp unit to encode INT96 to (by default
+        this is "ns", if you know the output parquet came from a system that encodes timestamp to a particular unit
+        then set this to that same unit e.g. coerce_int96_timestamp_unit="ms").
 
     Returns
     -------
@@ -837,6 +854,7 @@ def read_sql_query(
                 use_threads=use_threads,
                 session=session,
                 s3_additional_kwargs=s3_additional_kwargs,
+                pyarrow_additional_kwargs=pyarrow_additional_kwargs,
             )
         except Exception as e:  # pylint: disable=broad-except
             _logger.error(e)  # if there is anything wrong with the cache, just fallback to the usual path
@@ -859,6 +877,7 @@ def read_sql_query(
         use_threads=use_threads,
         s3_additional_kwargs=s3_additional_kwargs,
         boto3_session=session,
+        pyarrow_additional_kwargs=pyarrow_additional_kwargs,
     )
 
 
@@ -885,6 +904,7 @@ def read_sql_table(
     max_local_cache_entries: int = 100,
     data_source: Optional[str] = None,
     s3_additional_kwargs: Optional[Dict[str, Any]] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Union[pd.DataFrame, Iterator[pd.DataFrame]]:
     """Extract the full table AWS Athena and return the results as a Pandas DataFrame.
 
@@ -1045,6 +1065,15 @@ def read_sql_table(
     s3_additional_kwargs : Optional[Dict[str, Any]]
         Forwarded to botocore requests.
         e.g. s3_additional_kwargs={'RequestPayer': 'requester'}
+    pyarrow_additional_kwargs : Optional[Dict[str, Any]]
+        Forward to the ParquetFile class or converting an Arrow table to Pandas, currently only an
+        "coerce_int96_timestamp_unit" or "timestamp_as_object" argument will be considered. If
+        reading parquet fileswhere you cannot convert a timestamp to pandas Timestamp[ns] consider
+        setting timestamp_as_object=True, to allow for timestamp units > NS. If reading parquet data that
+        still uses INT96 (like Athena outputs) you can use coerce_int96_timestamp_unit to specify what
+        timestamp unit to encode INT96 to (by default this is "ns", if you know the output parquet came from
+        a system that encodes timestamp to a particular unit then set this to that same unit e.g.
+        coerce_int96_timestamp_unit="ms").
 
     Returns
     -------
@@ -1081,6 +1110,7 @@ def read_sql_table(
         max_remote_cache_entries=max_remote_cache_entries,
         max_local_cache_entries=max_local_cache_entries,
         s3_additional_kwargs=s3_additional_kwargs,
+        pyarrow_additional_kwargs=pyarrow_additional_kwargs,
     )
 
 
diff --git a/awswrangler/s3/_read_parquet.py b/awswrangler/s3/_read_parquet.py
--- a/awswrangler/s3/_read_parquet.py
+++ b/awswrangler/s3/_read_parquet.py
@@ -35,10 +35,12 @@
 
 
 def _pyarrow_parquet_file_wrapper(
-    source: Any, read_dictionary: Optional[List[str]] = None
+    source: Any, read_dictionary: Optional[List[str]] = None, coerce_int96_timestamp_unit: Optional[str] = None
 ) -> pyarrow.parquet.ParquetFile:
     try:
-        return pyarrow.parquet.ParquetFile(source=source, read_dictionary=read_dictionary)
+        return pyarrow.parquet.ParquetFile(
+            source=source, read_dictionary=read_dictionary, coerce_int96_timestamp_unit=coerce_int96_timestamp_unit
+        )
     except pyarrow.ArrowInvalid as ex:
         if str(ex) == "Parquet file size is 0 bytes":
             _logger.warning("Ignoring empty file...xx")
@@ -52,7 +54,9 @@ def _read_parquet_metadata_file(
     s3_additional_kwargs: Optional[Dict[str, str]],
     use_threads: Union[bool, int],
     version_id: Optional[str] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Optional[Dict[str, str]]:
+    pyarrow_args = _set_default_pyarrow_additional_kwargs(pyarrow_additional_kwargs)
     with open_s3_object(
         path=path,
         mode="rb",
@@ -62,7 +66,9 @@ def _read_parquet_metadata_file(
         s3_additional_kwargs=s3_additional_kwargs,
         boto3_session=boto3_session,
     ) as f:
-        pq_file: Optional[pyarrow.parquet.ParquetFile] = _pyarrow_parquet_file_wrapper(source=f)
+        pq_file: Optional[pyarrow.parquet.ParquetFile] = _pyarrow_parquet_file_wrapper(
+            source=f, coerce_int96_timestamp_unit=pyarrow_args["coerce_int96_timestamp_unit"]
+        )
         if pq_file is None:
             return None
         return _data_types.athena_types_from_pyarrow_schema(schema=pq_file.schema.to_arrow_schema(), partitions=None)[0]
@@ -75,7 +81,9 @@ def _read_schemas_from_files(
     boto3_session: boto3.Session,
     s3_additional_kwargs: Optional[Dict[str, str]],
     version_ids: Optional[Dict[str, str]] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Tuple[Dict[str, str], ...]:
+
     paths = _utils.list_sampling(lst=paths, sampling=sampling)
     schemas: Tuple[Optional[Dict[str, str]], ...] = tuple()
     n_paths: int = len(paths)
@@ -88,6 +96,7 @@ def _read_schemas_from_files(
                 s3_additional_kwargs=s3_additional_kwargs,
                 use_threads=use_threads,
                 version_id=version_ids.get(p) if isinstance(version_ids, dict) else None,
+                pyarrow_additional_kwargs=pyarrow_additional_kwargs,
             )
             for p in paths
         )
@@ -102,6 +111,7 @@ def _read_schemas_from_files(
                     itertools.repeat(s3_additional_kwargs),
                     itertools.repeat(use_threads),
                     versions,
+                    itertools.repeat(pyarrow_additional_kwargs),
                 )
             )
     schemas = cast(Tuple[Dict[str, str], ...], tuple(x for x in schemas if x is not None))
@@ -128,6 +138,7 @@ def _validate_schemas_from_files(
     boto3_session: boto3.Session,
     s3_additional_kwargs: Optional[Dict[str, str]],
     version_ids: Optional[Dict[str, str]] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> None:
     schemas: Tuple[Dict[str, str], ...] = _read_schemas_from_files(
         paths=paths,
@@ -136,6 +147,7 @@ def _validate_schemas_from_files(
         boto3_session=boto3_session,
         s3_additional_kwargs=s3_additional_kwargs,
         version_ids=version_ids,
+        pyarrow_additional_kwargs=pyarrow_additional_kwargs,
     )
     _validate_schemas(schemas=schemas)
 
@@ -164,6 +176,7 @@ def _read_parquet_metadata(
     boto3_session: boto3.Session,
     s3_additional_kwargs: Optional[Dict[str, str]],
     version_id: Optional[Union[str, Dict[str, str]]] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Tuple[Dict[str, str], Optional[Dict[str, str]], Optional[Dict[str, List[str]]]]:
     """Handle wr.s3.read_parquet_metadata internally."""
     path_root: Optional[str] = _get_path_root(path=path, dataset=dataset)
@@ -188,6 +201,7 @@ def _read_parquet_metadata(
         else {paths[0]: version_id}
         if isinstance(version_id, str)
         else None,
+        pyarrow_additional_kwargs=pyarrow_additional_kwargs,
     )
     columns_types: Dict[str, str] = _merge_schemas(schemas=schemas)
 
@@ -266,10 +280,12 @@ def _arrowtable2df(
     dataset: bool,
     path: str,
     path_root: Optional[str],
+    timestamp_as_object: bool = False,
 ) -> pd.DataFrame:
     metadata: Dict[str, Any] = {}
     if table.schema.metadata is not None and b"pandas" in table.schema.metadata:
         metadata = json.loads(table.schema.metadata[b"pandas"])
+
     if type(use_threads) == int:  # pylint: disable=unidiomatic-typecheck
         use_threads = bool(use_threads > 1)
     df: pd.DataFrame = _apply_partitions(
@@ -279,6 +295,7 @@ def _arrowtable2df(
             self_destruct=True,
             integer_object_nulls=False,
             date_as_object=True,
+            timestamp_as_object=timestamp_as_object,
             ignore_metadata=True,
             strings_to_categorical=False,
             safe=safe,
@@ -344,9 +361,12 @@ def _read_parquet_chunked(  # pylint: disable=too-many-branches
     s3_additional_kwargs: Optional[Dict[str, str]],
     use_threads: Union[bool, int],
     version_ids: Optional[Dict[str, str]] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Iterator[pd.DataFrame]:
     next_slice: Optional[pd.DataFrame] = None
     last_schema: Optional[Dict[str, str]] = None
+
+    pyarrow_args = _set_default_pyarrow_additional_kwargs(pyarrow_additional_kwargs)
     last_path: str = ""
     for path in paths:
         with open_s3_object(
@@ -359,7 +379,9 @@ def _read_parquet_chunked(  # pylint: disable=too-many-branches
             boto3_session=boto3_session,
         ) as f:
             pq_file: Optional[pyarrow.parquet.ParquetFile] = _pyarrow_parquet_file_wrapper(
-                source=f, read_dictionary=categories
+                source=f,
+                read_dictionary=categories,
+                coerce_int96_timestamp_unit=pyarrow_args["coerce_int96_timestamp_unit"],
             )
             if pq_file is None:
                 continue
@@ -399,6 +421,7 @@ def _read_parquet_chunked(  # pylint: disable=too-many-branches
                     dataset=dataset,
                     path=path,
                     path_root=path_root,
+                    timestamp_as_object=pyarrow_args["timestamp_as_object"],
                 )
                 if chunked is True:
                     yield df
@@ -426,7 +449,9 @@ def _read_parquet_file(
     s3_additional_kwargs: Optional[Dict[str, str]],
     use_threads: Union[bool, int],
     version_id: Optional[str] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> pa.Table:
+    pyarrow_args = _set_default_pyarrow_additional_kwargs(pyarrow_additional_kwargs)
     s3_block_size: int = 20_971_520 if columns else -1  # One shot for a full read otherwise 20 MB (20 * 2**20)
     with open_s3_object(
         path=path,
@@ -438,7 +463,9 @@ def _read_parquet_file(
         boto3_session=boto3_session,
     ) as f:
         pq_file: Optional[pyarrow.parquet.ParquetFile] = _pyarrow_parquet_file_wrapper(
-            source=f, read_dictionary=categories
+            source=f,
+            read_dictionary=categories,
+            coerce_int96_timestamp_unit=pyarrow_args["coerce_int96_timestamp_unit"],
         )
         if pq_file is None:
             raise exceptions.InvalidFile(f"Invalid Parquet file: {path}")
@@ -483,7 +510,9 @@ def _read_parquet(
     path_root: Optional[str],
     s3_additional_kwargs: Optional[Dict[str, str]],
     use_threads: Union[bool, int],
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> pd.DataFrame:
+    pyarrow_args = _set_default_pyarrow_additional_kwargs(pyarrow_additional_kwargs)
     boto3_session = _utils.ensure_session(boto3_session)
     return _arrowtable2df(
         table=_read_parquet_file(
@@ -494,6 +523,7 @@ def _read_parquet(
             s3_additional_kwargs=s3_additional_kwargs,
             use_threads=use_threads,
             version_id=version_id,
+            pyarrow_additional_kwargs=pyarrow_args,
         ),
         categories=categories,
         safe=safe,
@@ -502,6 +532,7 @@ def _read_parquet(
         dataset=dataset,
         path=path,
         path_root=path_root,
+        timestamp_as_object=pyarrow_args["timestamp_as_object"],
     )
 
 
@@ -526,6 +557,7 @@ def read_parquet(
     last_modified_end: Optional[datetime.datetime] = None,
     boto3_session: Optional[boto3.Session] = None,
     s3_additional_kwargs: Optional[Dict[str, Any]] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Union[pd.DataFrame, Iterator[pd.DataFrame]]:
     """Read Apache Parquet file(s) from from a received S3 prefix or list of S3 objects paths.
 
@@ -627,6 +659,14 @@ def read_parquet(
         Boto3 Session. The default boto3 session will be used if boto3_session receive None.
     s3_additional_kwargs : Optional[Dict[str, Any]]
         Forward to botocore requests, only "SSECustomerAlgorithm" and "SSECustomerKey" arguments will be considered.
+    pyarrow_additional_kwargs : Optional[Dict[str, Any]]
+        Forward to the ParquetFile class or converting an Arrow table to Pandas, currently only an
+        "coerce_int96_timestamp_unit" or "timestamp_as_object" argument will be considered. If reading parquet
+        files where you cannot convert a timestamp to pandas Timestamp[ns] consider setting timestamp_as_object=True,
+        to allow for timestamp units larger than "ns". If reading parquet data that still uses INT96 (like Athena
+        outputs) you can use coerce_int96_timestamp_unit to specify what timestamp unit to encode INT96 to (by default
+        this is "ns", if you know the output parquet came from a system that encodes timestamp to a particular unit
+        then set this to that same unit e.g. coerce_int96_timestamp_unit="ms").
 
     Returns
     -------
@@ -698,6 +738,7 @@ def read_parquet(
         "path_root": path_root,
         "s3_additional_kwargs": s3_additional_kwargs,
         "use_threads": use_threads,
+        "pyarrow_additional_kwargs": pyarrow_additional_kwargs,
     }
     _logger.debug("args:\n%s", pprint.pformat(args))
     if chunked is not False:
@@ -939,6 +980,7 @@ def read_parquet_metadata(
     use_threads: bool = True,
     boto3_session: Optional[boto3.Session] = None,
     s3_additional_kwargs: Optional[Dict[str, Any]] = None,
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]] = None,
 ) -> Tuple[Dict[str, str], Optional[Dict[str, str]]]:
     """Read Apache Parquet file(s) metadata from from a received S3 prefix or list of S3 objects paths.
 
@@ -990,6 +1032,9 @@ def read_parquet_metadata(
         Boto3 Session. The default boto3 session will be used if boto3_session receive None.
     s3_additional_kwargs : Optional[Dict[str, Any]]
         Forward to botocore requests, only "SSECustomerAlgorithm" and "SSECustomerKey" arguments will be considered.
+    pyarrow_additional_kwargs: Optional[Dict[str, Any]]
+        Forward kwargs to parquet reader currently only excepts "coerce_int96_timestamp_unit". Which can be used to cast
+        deprecated Parquet INT96 into a specified timestamp unit (e.g. "ms").
 
     Returns
     -------
@@ -1027,4 +1072,19 @@ def read_parquet_metadata(
         use_threads=use_threads,
         s3_additional_kwargs=s3_additional_kwargs,
         boto3_session=_utils.ensure_session(session=boto3_session),
+        pyarrow_additional_kwargs=pyarrow_additional_kwargs,
     )[:2]
+
+
+def _set_default_pyarrow_additional_kwargs(pyarrow_additional_kwargs: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+    if pyarrow_additional_kwargs is None:
+        pyarrow_additional_kwargs = {}
+    defaults = {
+        "coerce_int96_timestamp_unit": None,
+        "timestamp_as_object": False,
+    }
+    defaulted_args = {
+        **defaults,
+        **pyarrow_additional_kwargs,
+    }
+    return defaulted_args


Test patch:
diff --git a/tests/test_athena_parquet.py b/tests/test_athena_parquet.py
--- a/tests/test_athena_parquet.py
+++ b/tests/test_athena_parquet.py
@@ -752,3 +752,18 @@ def test_ignore_suffix(glue_database, glue_table, path):
     boto3.client("s3").put_object(Body=b"garbage", Bucket=bucket, Key=f"{directory}to_be_ignored")
     df2 = wr.s3.read_parquet_table(database=glue_database, table=glue_table, filename_ignore_suffix="ignored")
     assert df2.shape == df.shape
+
+
+def test_athena_timestamp_overflow():
+    sql = "SELECT timestamp '2262-04-11 23:47:17' AS c0"
+    df1 = wr.athena.read_sql_query(sql, "default")
+
+    df_overflow = pd.DataFrame({"c0": [pd.Timestamp("1677-09-21 00:12:43.290448384")]})
+    assert df_overflow.c0.values[0] == df1.c0.values[0]
+
+    df2 = wr.athena.read_sql_query(
+        sql, "default", pyarrow_additional_kwargs={"coerce_int96_timestamp_unit": "ms", "timestamp_as_object": True}
+    )
+
+    df_overflow_fix = pd.DataFrame({"c0": [datetime.datetime(2262, 4, 11, 23, 47, 17)]})
+    df_overflow_fix.c0.values[0] == df2.c0.values[0]