Repository: pandas-dev/pandas
Pull request #57360 (2024-02-11 11:41:16)

CI/DOC: Enforce GL08 in CI
Enforce GL08 in CI so that it can be fixed and enforced in chunks like other docstring errors.

The added functions come from the result of running `scripts/validate_docstrings.py --format=actions --errors=GL08`

Part of our ongoing work to continue enforcing numpydoc validations: https://numpydoc.readthedocs.io/en/latest/validation.html#built-in-validation-checks

- [x]  xref https://github.com/pandas-dev/pandas/pull/56971
- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [x] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [ ] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [ ] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.


jordan-d-murphy: pre-commit.ci autofix


jordan-d-murphy: @mroeschke merge conflicts resolved in code_checks
this is the last one! 


Patch:
diff --git a/ci/code_checks.sh b/ci/code_checks.sh
index 20ced8a513..f3666647f4 100755
--- a/ci/code_checks.sh
+++ b/ci/code_checks.sh
@@ -141,6 +141,279 @@ if [[ -z "$CHECK" || "$CHECK" == "docstrings" ]]; then
         pandas.core.groupby.SeriesGroupBy.plot # There should be no backslash in the final line, please keep this comment in the last ignored function
     RET=$(($RET + $?)) ; echo $MSG "DONE"
 
+    MSG='Partially validate docstrings (GL08)' ;  echo $MSG
+    $BASE_DIR/scripts/validate_docstrings.py --format=actions --errors=GL08 --ignore_functions \
+        pandas.DatetimeIndex.as_unit\
+        pandas.DatetimeIndex.freq\
+        pandas.ExcelFile.book\
+        pandas.ExcelFile.sheet_names\
+        pandas.Index.empty\
+        pandas.Index.names\
+        pandas.Index.view\
+        pandas.IntervalIndex.left\
+        pandas.IntervalIndex.length\
+        pandas.IntervalIndex.mid\
+        pandas.IntervalIndex.right\
+        pandas.MultiIndex.codes\
+        pandas.Period.freq\
+        pandas.Period.ordinal\
+        pandas.PeriodIndex.freq\
+        pandas.PeriodIndex.qyear\
+        pandas.Series.dt\
+        pandas.Series.dt.as_unit\
+        pandas.Series.dt.freq\
+        pandas.Series.dt.qyear\
+        pandas.Series.dt.unit\
+        pandas.Series.empty\
+        pandas.Timedelta.microseconds\
+        pandas.Timedelta.unit\
+        pandas.Timedelta.value\
+        pandas.Timestamp.day\
+        pandas.Timestamp.fold\
+        pandas.Timestamp.hour\
+        pandas.Timestamp.microsecond\
+        pandas.Timestamp.minute\
+        pandas.Timestamp.month\
+        pandas.Timestamp.nanosecond\
+        pandas.Timestamp.second\
+        pandas.Timestamp.tzinfo\
+        pandas.Timestamp.value\
+        pandas.Timestamp.year\
+        pandas.core.groupby.SeriesGroupBy.value_counts\
+        pandas.tseries.offsets.BQuarterBegin.is_anchored\
+        pandas.tseries.offsets.BQuarterBegin.is_on_offset\
+        pandas.tseries.offsets.BQuarterBegin.n\
+        pandas.tseries.offsets.BQuarterBegin.nanos\
+        pandas.tseries.offsets.BQuarterBegin.normalize\
+        pandas.tseries.offsets.BQuarterBegin.rule_code\
+        pandas.tseries.offsets.BQuarterBegin.startingMonth\
+        pandas.tseries.offsets.BQuarterEnd.is_anchored\
+        pandas.tseries.offsets.BQuarterEnd.is_on_offset\
+        pandas.tseries.offsets.BQuarterEnd.n\
+        pandas.tseries.offsets.BQuarterEnd.nanos\
+        pandas.tseries.offsets.BQuarterEnd.normalize\
+        pandas.tseries.offsets.BQuarterEnd.rule_code\
+        pandas.tseries.offsets.BQuarterEnd.startingMonth\
+        pandas.tseries.offsets.BYearBegin.is_on_offset\
+        pandas.tseries.offsets.BYearBegin.month\
+        pandas.tseries.offsets.BYearBegin.n\
+        pandas.tseries.offsets.BYearBegin.nanos\
+        pandas.tseries.offsets.BYearBegin.normalize\
+        pandas.tseries.offsets.BYearBegin.rule_code\
+        pandas.tseries.offsets.BYearEnd.is_on_offset\
+        pandas.tseries.offsets.BYearEnd.month\
+        pandas.tseries.offsets.BYearEnd.n\
+        pandas.tseries.offsets.BYearEnd.nanos\
+        pandas.tseries.offsets.BYearEnd.normalize\
+        pandas.tseries.offsets.BYearEnd.rule_code\
+        pandas.tseries.offsets.BusinessDay.calendar\
+        pandas.tseries.offsets.BusinessDay.holidays\
+        pandas.tseries.offsets.BusinessDay.is_on_offset\
+        pandas.tseries.offsets.BusinessDay.n\
+        pandas.tseries.offsets.BusinessDay.nanos\
+        pandas.tseries.offsets.BusinessDay.normalize\
+        pandas.tseries.offsets.BusinessDay.rule_code\
+        pandas.tseries.offsets.BusinessDay.weekmask\
+        pandas.tseries.offsets.BusinessHour.calendar\
+        pandas.tseries.offsets.BusinessHour.end\
+        pandas.tseries.offsets.BusinessHour.holidays\
+        pandas.tseries.offsets.BusinessHour.is_on_offset\
+        pandas.tseries.offsets.BusinessHour.n\
+        pandas.tseries.offsets.BusinessHour.nanos\
+        pandas.tseries.offsets.BusinessHour.normalize\
+        pandas.tseries.offsets.BusinessHour.rule_code\
+        pandas.tseries.offsets.BusinessHour.start\
+        pandas.tseries.offsets.BusinessHour.weekmask\
+        pandas.tseries.offsets.BusinessMonthBegin.is_on_offset\
+        pandas.tseries.offsets.BusinessMonthBegin.n\
+        pandas.tseries.offsets.BusinessMonthBegin.nanos\
+        pandas.tseries.offsets.BusinessMonthBegin.normalize\
+        pandas.tseries.offsets.BusinessMonthBegin.rule_code\
+        pandas.tseries.offsets.BusinessMonthEnd.is_on_offset\
+        pandas.tseries.offsets.BusinessMonthEnd.n\
+        pandas.tseries.offsets.BusinessMonthEnd.nanos\
+        pandas.tseries.offsets.BusinessMonthEnd.normalize\
+        pandas.tseries.offsets.BusinessMonthEnd.rule_code\
+        pandas.tseries.offsets.CustomBusinessDay.calendar\
+        pandas.tseries.offsets.CustomBusinessDay.holidays\
+        pandas.tseries.offsets.CustomBusinessDay.is_on_offset\
+        pandas.tseries.offsets.CustomBusinessDay.n\
+        pandas.tseries.offsets.CustomBusinessDay.nanos\
+        pandas.tseries.offsets.CustomBusinessDay.normalize\
+        pandas.tseries.offsets.CustomBusinessDay.rule_code\
+        pandas.tseries.offsets.CustomBusinessDay.weekmask\
+        pandas.tseries.offsets.CustomBusinessHour.calendar\
+        pandas.tseries.offsets.CustomBusinessHour.end\
+        pandas.tseries.offsets.CustomBusinessHour.holidays\
+        pandas.tseries.offsets.CustomBusinessHour.is_on_offset\
+        pandas.tseries.offsets.CustomBusinessHour.n\
+        pandas.tseries.offsets.CustomBusinessHour.nanos\
+        pandas.tseries.offsets.CustomBusinessHour.normalize\
+        pandas.tseries.offsets.CustomBusinessHour.rule_code\
+        pandas.tseries.offsets.CustomBusinessHour.start\
+        pandas.tseries.offsets.CustomBusinessHour.weekmask\
+        pandas.tseries.offsets.CustomBusinessMonthBegin.calendar\
+        pandas.tseries.offsets.CustomBusinessMonthBegin.holidays\
+        pandas.tseries.offsets.CustomBusinessMonthBegin.m_offset\
+        pandas.tseries.offsets.CustomBusinessMonthBegin.n\
+        pandas.tseries.offsets.CustomBusinessMonthBegin.nanos\
+        pandas.tseries.offsets.CustomBusinessMonthBegin.normalize\
+        pandas.tseries.offsets.CustomBusinessMonthBegin.rule_code\
+        pandas.tseries.offsets.CustomBusinessMonthBegin.weekmask\
+        pandas.tseries.offsets.CustomBusinessMonthEnd.calendar\
+        pandas.tseries.offsets.CustomBusinessMonthEnd.holidays\
+        pandas.tseries.offsets.CustomBusinessMonthEnd.m_offset\
+        pandas.tseries.offsets.CustomBusinessMonthEnd.n\
+        pandas.tseries.offsets.CustomBusinessMonthEnd.nanos\
+        pandas.tseries.offsets.CustomBusinessMonthEnd.normalize\
+        pandas.tseries.offsets.CustomBusinessMonthEnd.rule_code\
+        pandas.tseries.offsets.CustomBusinessMonthEnd.weekmask\
+        pandas.tseries.offsets.DateOffset.is_on_offset\
+        pandas.tseries.offsets.DateOffset.n\
+        pandas.tseries.offsets.DateOffset.nanos\
+        pandas.tseries.offsets.DateOffset.normalize\
+        pandas.tseries.offsets.DateOffset.rule_code\
+        pandas.tseries.offsets.Day.delta\
+        pandas.tseries.offsets.Day.is_on_offset\
+        pandas.tseries.offsets.Day.n\
+        pandas.tseries.offsets.Day.normalize\
+        pandas.tseries.offsets.Day.rule_code\
+        pandas.tseries.offsets.Easter.is_on_offset\
+        pandas.tseries.offsets.Easter.n\
+        pandas.tseries.offsets.Easter.nanos\
+        pandas.tseries.offsets.Easter.normalize\
+        pandas.tseries.offsets.Easter.rule_code\
+        pandas.tseries.offsets.FY5253.get_rule_code_suffix\
+        pandas.tseries.offsets.FY5253.get_year_end\
+        pandas.tseries.offsets.FY5253.is_anchored\
+        pandas.tseries.offsets.FY5253.is_on_offset\
+        pandas.tseries.offsets.FY5253.n\
+        pandas.tseries.offsets.FY5253.nanos\
+        pandas.tseries.offsets.FY5253.normalize\
+        pandas.tseries.offsets.FY5253.rule_code\
+        pandas.tseries.offsets.FY5253.startingMonth\
+        pandas.tseries.offsets.FY5253.variation\
+        pandas.tseries.offsets.FY5253.weekday\
+        pandas.tseries.offsets.FY5253Quarter.get_rule_code_suffix\
+        pandas.tseries.offsets.FY5253Quarter.get_weeks\
+        pandas.tseries.offsets.FY5253Quarter.is_anchored\
+        pandas.tseries.offsets.FY5253Quarter.is_on_offset\
+        pandas.tseries.offsets.FY5253Quarter.n\
+        pandas.tseries.offsets.FY5253Quarter.nanos\
+        pandas.tseries.offsets.FY5253Quarter.normalize\
+        pandas.tseries.offsets.FY5253Quarter.qtr_with_extra_week\
+        pandas.tseries.offsets.FY5253Quarter.rule_code\
+        pandas.tseries.offsets.FY5253Quarter.startingMonth\
+        pandas.tseries.offsets.FY5253Quarter.variation\
+        pandas.tseries.offsets.FY5253Quarter.weekday\
+        pandas.tseries.offsets.FY5253Quarter.year_has_extra_week\
+        pandas.tseries.offsets.Hour.delta\
+        pandas.tseries.offsets.Hour.is_on_offset\
+        pandas.tseries.offsets.Hour.n\
+        pandas.tseries.offsets.Hour.normalize\
+        pandas.tseries.offsets.Hour.rule_code\
+        pandas.tseries.offsets.LastWeekOfMonth.is_on_offset\
+        pandas.tseries.offsets.LastWeekOfMonth.n\
+        pandas.tseries.offsets.LastWeekOfMonth.nanos\
+        pandas.tseries.offsets.LastWeekOfMonth.normalize\
+        pandas.tseries.offsets.LastWeekOfMonth.rule_code\
+        pandas.tseries.offsets.LastWeekOfMonth.week\
+        pandas.tseries.offsets.LastWeekOfMonth.weekday\
+        pandas.tseries.offsets.Micro.delta\
+        pandas.tseries.offsets.Micro.is_on_offset\
+        pandas.tseries.offsets.Micro.n\
+        pandas.tseries.offsets.Micro.normalize\
+        pandas.tseries.offsets.Micro.rule_code\
+        pandas.tseries.offsets.Milli.delta\
+        pandas.tseries.offsets.Milli.is_on_offset\
+        pandas.tseries.offsets.Milli.n\
+        pandas.tseries.offsets.Milli.normalize\
+        pandas.tseries.offsets.Milli.rule_code\
+        pandas.tseries.offsets.Minute.delta\
+        pandas.tseries.offsets.Minute.is_on_offset\
+        pandas.tseries.offsets.Minute.n\
+        pandas.tseries.offsets.Minute.normalize\
+        pandas.tseries.offsets.Minute.rule_code\
+        pandas.tseries.offsets.MonthBegin.is_on_offset\
+        pandas.tseries.offsets.MonthBegin.n\
+        pandas.tseries.offsets.MonthBegin.nanos\
+        pandas.tseries.offsets.MonthBegin.normalize\
+        pandas.tseries.offsets.MonthBegin.rule_code\
+        pandas.tseries.offsets.MonthEnd.is_on_offset\
+        pandas.tseries.offsets.MonthEnd.n\
+        pandas.tseries.offsets.MonthEnd.nanos\
+        pandas.tseries.offsets.MonthEnd.normalize\
+        pandas.tseries.offsets.MonthEnd.rule_code\
+        pandas.tseries.offsets.Nano.delta\
+        pandas.tseries.offsets.Nano.is_on_offset\
+        pandas.tseries.offsets.Nano.n\
+        pandas.tseries.offsets.Nano.normalize\
+        pandas.tseries.offsets.Nano.rule_code\
+        pandas.tseries.offsets.QuarterBegin.is_anchored\
+        pandas.tseries.offsets.QuarterBegin.is_on_offset\
+        pandas.tseries.offsets.QuarterBegin.n\
+        pandas.tseries.offsets.QuarterBegin.nanos\
+        pandas.tseries.offsets.QuarterBegin.normalize\
+        pandas.tseries.offsets.QuarterBegin.rule_code\
+        pandas.tseries.offsets.QuarterBegin.startingMonth\
+        pandas.tseries.offsets.QuarterEnd.is_anchored\
+        pandas.tseries.offsets.QuarterEnd.is_on_offset\
+        pandas.tseries.offsets.QuarterEnd.n\
+        pandas.tseries.offsets.QuarterEnd.nanos\
+        pandas.tseries.offsets.QuarterEnd.normalize\
+        pandas.tseries.offsets.QuarterEnd.rule_code\
+        pandas.tseries.offsets.QuarterEnd.startingMonth\
+        pandas.tseries.offsets.Second.delta\
+        pandas.tseries.offsets.Second.is_on_offset\
+        pandas.tseries.offsets.Second.n\
+        pandas.tseries.offsets.Second.normalize\
+        pandas.tseries.offsets.Second.rule_code\
+        pandas.tseries.offsets.SemiMonthBegin.day_of_month\
+        pandas.tseries.offsets.SemiMonthBegin.is_on_offset\
+        pandas.tseries.offsets.SemiMonthBegin.n\
+        pandas.tseries.offsets.SemiMonthBegin.nanos\
+        pandas.tseries.offsets.SemiMonthBegin.normalize\
+        pandas.tseries.offsets.SemiMonthBegin.rule_code\
+        pandas.tseries.offsets.SemiMonthEnd.day_of_month\
+        pandas.tseries.offsets.SemiMonthEnd.is_on_offset\
+        pandas.tseries.offsets.SemiMonthEnd.n\
+        pandas.tseries.offsets.SemiMonthEnd.nanos\
+        pandas.tseries.offsets.SemiMonthEnd.normalize\
+        pandas.tseries.offsets.SemiMonthEnd.rule_code\
+        pandas.tseries.offsets.Tick\
+        pandas.tseries.offsets.Tick.delta\
+        pandas.tseries.offsets.Tick.is_on_offset\
+        pandas.tseries.offsets.Tick.n\
+        pandas.tseries.offsets.Tick.normalize\
+        pandas.tseries.offsets.Tick.rule_code\
+        pandas.tseries.offsets.Week.is_anchored\
+        pandas.tseries.offsets.Week.is_on_offset\
+        pandas.tseries.offsets.Week.n\
+        pandas.tseries.offsets.Week.nanos\
+        pandas.tseries.offsets.Week.normalize\
+        pandas.tseries.offsets.Week.rule_code\
+        pandas.tseries.offsets.Week.weekday\
+        pandas.tseries.offsets.WeekOfMonth.is_on_offset\
+        pandas.tseries.offsets.WeekOfMonth.n\
+        pandas.tseries.offsets.WeekOfMonth.nanos\
+        pandas.tseries.offsets.WeekOfMonth.normalize\
+        pandas.tseries.offsets.WeekOfMonth.rule_code\
+        pandas.tseries.offsets.WeekOfMonth.week\
+        pandas.tseries.offsets.WeekOfMonth.weekday\
+        pandas.tseries.offsets.YearBegin.is_on_offset\
+        pandas.tseries.offsets.YearBegin.month\
+        pandas.tseries.offsets.YearBegin.n\
+        pandas.tseries.offsets.YearBegin.nanos\
+        pandas.tseries.offsets.YearBegin.normalize\
+        pandas.tseries.offsets.YearBegin.rule_code\
+        pandas.tseries.offsets.YearEnd.is_on_offset\
+        pandas.tseries.offsets.YearEnd.month\
+        pandas.tseries.offsets.YearEnd.n\
+        pandas.tseries.offsets.YearEnd.nanos\
+        pandas.tseries.offsets.YearEnd.normalize\
+        pandas.tseries.offsets.YearEnd.rule_code # There should be no backslash in the final line, please keep this comment in the last ignored function
+    RET=$(($RET + $?)) ; echo $MSG "DONE"
+
     MSG='Partially validate docstrings (ES01)' ;  echo $MSG
     $BASE_DIR/scripts/validate_docstrings.py --format=actions --errors=ES01 --ignore_functions \
         pandas.Categorical.__array__\
diff --git a/doc/source/user_guide/io.rst b/doc/source/user_guide/io.rst
index a083158183..060db772b9 100644
--- a/doc/source/user_guide/io.rst
+++ b/doc/source/user_guide/io.rst
@@ -3908,6 +3908,20 @@ The look and feel of Excel worksheets created from pandas can be modified using
 * ``float_format`` : Format string for floating point numbers (default ``None``).
 * ``freeze_panes`` : A tuple of two integers representing the bottommost row and rightmost column to freeze. Each of these parameters is one-based, so (1, 1) will freeze the first row and first column (default ``None``).
 
+.. note::
+
+    As of Pandas 3.0, by default spreadsheets created with the ``to_excel`` method
+    will not contain any styling. Users wishing to bold text, add bordered styles,
+    etc in a worksheet output by ``to_excel`` can do so by using :meth:`Styler.to_excel`
+    to create styled excel files. For documentation on styling spreadsheets, see
+    `here <https://pandas.pydata.org/docs/user_guide/style.html#Export-to-Excel>`__.
+
+
+.. code-block:: python
+
+    css = "border: 1px solid black; font-weight: bold;"
+    df.style.map_index(lambda x: css).map_index(lambda x: css, axis=1).to_excel("myfile.xlsx")
+
 Using the `Xlsxwriter`_ engine provides many options for controlling the
 format of an Excel worksheet created with the ``to_excel`` method.  Excellent examples can be found in the
 `Xlsxwriter`_ documentation here: https://xlsxwriter.readthedocs.io/working_with_pandas.html
diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst
index 54cd598939..483cf65908 100644
--- a/doc/source/whatsnew/v3.0.0.rst
+++ b/doc/source/whatsnew/v3.0.0.rst
@@ -30,6 +30,7 @@ Other enhancements
 ^^^^^^^^^^^^^^^^^^
 - :func:`DataFrame.to_excel` now raises an ``UserWarning`` when the character count in a cell exceeds Excel's limitation of 32767 characters (:issue:`56954`)
 - :func:`read_stata` now returns ``datetime64`` resolutions better matching those natively stored in the stata format (:issue:`55642`)
+- :meth:`Styler.set_tooltips` provides alternative method to storing tooltips by using title attribute of td elements. (:issue:`56981`)
 - Allow dictionaries to be passed to :meth:`pandas.Series.str.replace` via ``pat`` parameter (:issue:`51748`)
 - Support passing a :class:`Series` input to :func:`json_normalize` that retains the :class:`Series` :class:`Index` (:issue:`51452`)
 -
@@ -90,6 +91,7 @@ Other API changes
 - 3rd party ``py.path`` objects are no longer explicitly supported in IO methods. Use :py:class:`pathlib.Path` objects instead (:issue:`57091`)
 - :attr:`MultiIndex.codes`, :attr:`MultiIndex.levels`, and :attr:`MultiIndex.names` now returns a ``tuple`` instead of a ``FrozenList`` (:issue:`53531`)
 - Made ``dtype`` a required argument in :meth:`ExtensionArray._from_sequence_of_strings` (:issue:`56519`)
+- Updated :meth:`DataFrame.to_excel` so that the output spreadsheet has no styling. Custom styling can still be done using :meth:`Styler.to_excel` (:issue:`54154`)
 - pickle and HDF (``.h5``) files created with Python 2 are no longer explicitly supported (:issue:`57387`)
 -
 
@@ -274,7 +276,6 @@ ExtensionArray
 Styler
 ^^^^^^
 -
--
 
 Other
 ^^^^^
diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py
index 892f69e763..504b5aa560 100644
--- a/pandas/io/formats/excel.py
+++ b/pandas/io/formats/excel.py
@@ -582,19 +582,6 @@ class ExcelFormatter:
         self.merge_cells = merge_cells
         self.inf_rep = inf_rep
 
-    @property
-    def header_style(self) -> dict[str, dict[str, str | bool]]:
-        return {
-            "font": {"bold": True},
-            "borders": {
-                "top": "thin",
-                "right": "thin",
-                "bottom": "thin",
-                "left": "thin",
-            },
-            "alignment": {"horizontal": "center", "vertical": "top"},
-        }
-
     def _format_value(self, val):
         if is_scalar(val) and missing.isna(val):
             val = self.na_rep
@@ -642,7 +629,7 @@ class ExcelFormatter:
                     row=lnum,
                     col=coloffset,
                     val=name,
-                    style=self.header_style,
+                    style=None,
                 )
 
             for lnum, (spans, levels, level_codes) in enumerate(
@@ -657,7 +644,7 @@ class ExcelFormatter:
                         row=lnum,
                         col=coloffset + i + 1,
                         val=values[i],
-                        style=self.header_style,
+                        style=None,
                         css_styles=getattr(self.styler, "ctx_columns", None),
                         css_row=lnum,
                         css_col=i,
@@ -673,7 +660,7 @@ class ExcelFormatter:
                     row=lnum,
                     col=coloffset + i + 1,
                     val=v,
-                    style=self.header_style,
+                    style=None,
                     css_styles=getattr(self.styler, "ctx_columns", None),
                     css_row=lnum,
                     css_col=i,
@@ -706,7 +693,7 @@ class ExcelFormatter:
                     row=self.rowcounter,
                     col=colindex + coloffset,
                     val=colname,
-                    style=self.header_style,
+                    style=None,
                     css_styles=getattr(self.styler, "ctx_columns", None),
                     css_row=0,
                     css_col=colindex,
@@ -729,7 +716,7 @@ class ExcelFormatter:
             ] * len(self.columns)
             if functools.reduce(lambda x, y: x and y, (x != "" for x in row)):
                 gen2 = (
-                    ExcelCell(self.rowcounter, colindex, val, self.header_style)
+                    ExcelCell(self.rowcounter, colindex, val, None)
                     for colindex, val in enumerate(row)
                 )
                 self.rowcounter += 1
@@ -763,7 +750,7 @@ class ExcelFormatter:
                 self.rowcounter += 1
 
             if index_label and self.header is not False:
-                yield ExcelCell(self.rowcounter - 1, 0, index_label, self.header_style)
+                yield ExcelCell(self.rowcounter - 1, 0, index_label, None)
 
             # write index_values
             index_values = self.df.index
@@ -775,7 +762,7 @@ class ExcelFormatter:
                     row=self.rowcounter + idx,
                     col=0,
                     val=idxval,
-                    style=self.header_style,
+                    style=None,
                     css_styles=getattr(self.styler, "ctx_index", None),
                     css_row=idx,
                     css_col=0,
@@ -811,7 +798,7 @@ class ExcelFormatter:
             # if index labels are not empty go ahead and dump
             if com.any_not_none(*index_labels) and self.header is not False:
                 for cidx, name in enumerate(index_labels):
-                    yield ExcelCell(self.rowcounter - 1, cidx, name, self.header_style)
+                    yield ExcelCell(self.rowcounter - 1, cidx, name, None)
 
             if self.merge_cells:
                 # Format hierarchical rows as merged cells.
@@ -838,7 +825,7 @@ class ExcelFormatter:
                             row=self.rowcounter + i,
                             col=gcolidx,
                             val=values[i],
-                            style=self.header_style,
+                            style=None,
                             css_styles=getattr(self.styler, "ctx_index", None),
                             css_row=i,
                             css_col=gcolidx,
@@ -856,7 +843,7 @@ class ExcelFormatter:
                             row=self.rowcounter + idx,
                             col=gcolidx,
                             val=indexcolval,
-                            style=self.header_style,
+                            style=None,
                             css_styles=getattr(self.styler, "ctx_index", None),
                             css_row=idx,
                             css_col=gcolidx,
diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py
index 31e025ace4..3f607ef63e 100644
--- a/pandas/io/formats/style.py
+++ b/pandas/io/formats/style.py
@@ -424,6 +424,7 @@ class Styler(StylerRenderer):
         ttips: DataFrame,
         props: CSSProperties | None = None,
         css_class: str | None = None,
+        as_title_attribute: bool = False,
     ) -> Styler:
         """
         Set the DataFrame of strings on ``Styler`` generating ``:hover`` tooltips.
@@ -447,6 +448,9 @@ class Styler(StylerRenderer):
             Name of the tooltip class used in CSS, should conform to HTML standards.
             Only useful if integrating tooltips with external CSS. If ``None`` uses the
             internal default value 'pd-t'.
+        as_title_attribute : bool, default False
+            Add the tooltip text as title attribute to resultant <td> element. If True
+            then props and css_class arguments are ignored.
 
         Returns
         -------
@@ -475,6 +479,12 @@ class Styler(StylerRenderer):
         additional HTML for larger tables, since they also require that ``cell_ids``
         is forced to `True`.
 
+        If multiline tooltips are required, or if styling is not required and/or
+        space is of concern, then utilizing as_title_attribute as True will store
+        the tooltip on the <td> title attribute. This will cause no CSS
+        to be generated nor will the <span> elements. Storing tooltips through
+        the title attribute will mean that tooltip styling effects do not apply.
+
         Examples
         --------
         Basic application
@@ -502,6 +512,10 @@ class Styler(StylerRenderer):
         ...     props="visibility:hidden; position:absolute; z-index:1;",
         ... )
         ... # doctest: +SKIP
+
+        Multiline tooltips with smaller size footprint
+
+        >>> df.style.set_tooltips(ttips, as_title_attribute=True)  # doctest: +SKIP
         """
         if not self.cell_ids:
             # tooltips not optimised for individual cell check. requires reasonable
@@ -516,10 +530,13 @@ class Styler(StylerRenderer):
         if self.tooltips is None:  # create a default instance if necessary
             self.tooltips = Tooltips()
         self.tooltips.tt_data = ttips
-        if props:
-            self.tooltips.class_properties = props
-        if css_class:
-            self.tooltips.class_name = css_class
+        if not as_title_attribute:
+            if props:
+                self.tooltips.class_properties = props
+            if css_class:
+                self.tooltips.class_name = css_class
+        else:
+            self.tooltips.as_title_attribute = as_title_attribute
 
         return self
 
diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py
index 1cf54dc2cc..fe03ba5196 100644
--- a/pandas/io/formats/style_render.py
+++ b/pandas/io/formats/style_render.py
@@ -1979,6 +1979,11 @@ class Tooltips:
     tooltips: DataFrame, default empty
         DataFrame of strings aligned with underlying Styler data for tooltip
         display.
+    as_title_attribute: bool, default False
+        Flag to use title attribute based tooltips (True) or <span> based
+        tooltips (False).
+        Add the tooltip text as title attribute to resultant <td> element. If
+        True, no CSS is generated and styling effects do not apply.
 
     Notes
     -----
@@ -2007,11 +2012,13 @@ class Tooltips:
         ],
         css_name: str = "pd-t",
         tooltips: DataFrame = DataFrame(),
+        as_title_attribute: bool = False,
     ) -> None:
         self.class_name = css_name
         self.class_properties = css_props
         self.tt_data = tooltips
         self.table_styles: CSSStyles = []
+        self.as_title_attribute = as_title_attribute
 
     @property
     def _class_styles(self):
@@ -2101,35 +2108,53 @@ class Tooltips:
         if self.tt_data.empty:
             return d
 
-        name = self.class_name
         mask = (self.tt_data.isna()) | (self.tt_data.eq(""))  # empty string = no ttip
-        self.table_styles = [
-            style
-            for sublist in [
-                self._pseudo_css(styler.uuid, name, i, j, str(self.tt_data.iloc[i, j]))
-                for i in range(len(self.tt_data.index))
-                for j in range(len(self.tt_data.columns))
-                if not (
-                    mask.iloc[i, j]
-                    or i in styler.hidden_rows
-                    or j in styler.hidden_columns
-                )
+        # this conditional adds tooltips via pseudo css and <span> elements.
+        if not self.as_title_attribute:
+            name = self.class_name
+            self.table_styles = [
+                style
+                for sublist in [
+                    self._pseudo_css(
+                        styler.uuid, name, i, j, str(self.tt_data.iloc[i, j])
+                    )
+                    for i in range(len(self.tt_data.index))
+                    for j in range(len(self.tt_data.columns))
+                    if not (
+                        mask.iloc[i, j]
+                        or i in styler.hidden_rows
+                        or j in styler.hidden_columns
+                    )
+                ]
+                for style in sublist
             ]
-            for style in sublist
-        ]
-
-        if self.table_styles:
-            # add span class to every cell only if at least 1 non-empty tooltip
-            for row in d["body"]:
-                for item in row:
-                    if item["type"] == "td":
-                        item["display_value"] = (
-                            str(item["display_value"])
-                            + f'<span class="{self.class_name}"></span>'
-                        )
-            d["table_styles"].extend(self._class_styles)
-            d["table_styles"].extend(self.table_styles)
 
+            # add span class to every cell since there is at least 1 non-empty tooltip
+            if self.table_styles:
+                for row in d["body"]:
+                    for item in row:
+                        if item["type"] == "td":
+                            item["display_value"] = (
+                                str(item["display_value"])
+                                + f'<span class="{self.class_name}"></span>'
+                            )
+                d["table_styles"].extend(self._class_styles)
+                d["table_styles"].extend(self.table_styles)
+        # this conditional adds tooltips as extra "title" attribute on a <td> element
+        else:
+            index_offset = self.tt_data.index.nlevels
+            body = d["body"]
+            for i in range(len(self.tt_data.index)):
+                for j in range(len(self.tt_data.columns)):
+                    if (
+                        not mask.iloc[i, j]
+                        or i in styler.hidden_rows
+                        or j in styler.hidden_columns
+                    ):
+                        row = body[i]
+                        item = row[j + index_offset]
+                        value = self.tt_data.iloc[i, j]
+                        item["attributes"] += f' title="{value}"'
         return d
 
 


Test patch:
diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py
index 3ca8637885..58b297e452 100644
--- a/pandas/tests/io/excel/test_style.py
+++ b/pandas/tests/io/excel/test_style.py
@@ -31,6 +31,28 @@ def assert_equal_cell_styles(cell1, cell2):
     assert cell1.protection.__dict__ == cell2.protection.__dict__
 
 
+def test_styler_default_values():
+    # GH 54154
+    openpyxl = pytest.importorskip("openpyxl")
+    df = DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}])
+
+    with tm.ensure_clean(".xlsx") as path:
+        with ExcelWriter(path, engine="openpyxl") as writer:
+            df.to_excel(writer, sheet_name="custom")
+
+        with contextlib.closing(openpyxl.load_workbook(path)) as wb:
+            # Check font, spacing, indentation
+            assert wb["custom"].cell(1, 1).font.bold is False
+            assert wb["custom"].cell(1, 1).alignment.horizontal is None
+            assert wb["custom"].cell(1, 1).alignment.vertical is None
+
+            # Check border
+            assert wb["custom"].cell(1, 1).border.bottom.color is None
+            assert wb["custom"].cell(1, 1).border.top.color is None
+            assert wb["custom"].cell(1, 1).border.left.color is None
+            assert wb["custom"].cell(1, 1).border.right.color is None
+
+
 @pytest.mark.parametrize(
     "engine",
     ["xlsxwriter", "openpyxl"],
@@ -123,6 +145,36 @@ shared_style_params = [
 ]
 
 
+def test_styler_custom_style():
+    # GH 54154
+    css_style = "background-color: #111222"
+    openpyxl = pytest.importorskip("openpyxl")
+    df = DataFrame([{"A": 1, "B": 2}, {"A": 1, "B": 2}])
+
+    with tm.ensure_clean(".xlsx") as path:
+        with ExcelWriter(path, engine="openpyxl") as writer:
+            styler = df.style.map(lambda x: css_style)
+            styler.to_excel(writer, sheet_name="custom", index=False)
+
+        with contextlib.closing(openpyxl.load_workbook(path)) as wb:
+            # Check font, spacing, indentation
+            assert wb["custom"].cell(1, 1).font.bold is False
+            assert wb["custom"].cell(1, 1).alignment.horizontal is None
+            assert wb["custom"].cell(1, 1).alignment.vertical is None
+
+            # Check border
+            assert wb["custom"].cell(1, 1).border.bottom.color is None
+            assert wb["custom"].cell(1, 1).border.top.color is None
+            assert wb["custom"].cell(1, 1).border.left.color is None
+            assert wb["custom"].cell(1, 1).border.right.color is None
+
+            # Check background color
+            assert wb["custom"].cell(2, 1).fill.fgColor.index == "00111222"
+            assert wb["custom"].cell(3, 1).fill.fgColor.index == "00111222"
+            assert wb["custom"].cell(2, 2).fill.fgColor.index == "00111222"
+            assert wb["custom"].cell(3, 2).fill.fgColor.index == "00111222"
+
+
 @pytest.mark.parametrize(
     "engine",
     ["xlsxwriter", "openpyxl"],
diff --git a/pandas/tests/io/formats/style/test_tooltip.py b/pandas/tests/io/formats/style/test_tooltip.py
index c49a0e05c6..f1071c9492 100644
--- a/pandas/tests/io/formats/style/test_tooltip.py
+++ b/pandas/tests/io/formats/style/test_tooltip.py
@@ -1,7 +1,10 @@
 import numpy as np
 import pytest
 
-from pandas import DataFrame
+from pandas import (
+    DataFrame,
+    MultiIndex,
+)
 
 pytest.importorskip("jinja2")
 from pandas.io.formats.style import Styler
@@ -22,19 +25,17 @@ def styler(df):
 
 
 @pytest.mark.parametrize(
-    "ttips",
+    "data, columns, index",
     [
-        DataFrame(  # Test basic reindex and ignoring blank
-            data=[["Min", "Max"], [np.nan, ""]],
-            columns=["A", "C"],
-            index=["x", "y"],
-        ),
-        DataFrame(  # Test non-referenced columns, reversed col names, short index
-            data=[["Max", "Min", "Bad-Col"]], columns=["C", "A", "D"], index=["x"]
-        ),
+        # Test basic reindex and ignoring blank
+        ([["Min", "Max"], [np.nan, ""]], ["A", "C"], ["x", "y"]),
+        # Test non-referenced columns, reversed col names, short index
+        ([["Max", "Min", "Bad-Col"]], ["C", "A", "D"], ["x"]),
     ],
 )
-def test_tooltip_render(ttips, styler):
+def test_tooltip_render(data, columns, index, styler):
+    ttips = DataFrame(data=data, columns=columns, index=index)
+
     # GH 21266
     result = styler.set_tooltips(ttips).to_html()
 
@@ -64,6 +65,7 @@ def test_tooltip_ignored(styler):
     result = styler.to_html()  # no set_tooltips() creates no <span>
     assert '<style type="text/css">\n</style>' in result
     assert '<span class="pd-t"></span>' not in result
+    assert 'title="' not in result
 
 
 def test_tooltip_css_class(styler):
@@ -83,3 +85,95 @@ def test_tooltip_css_class(styler):
         props="color:green;color:red;",
     ).to_html()
     assert "#T_ .another-class {\n  color: green;\n  color: red;\n}" in result
+
+
+@pytest.mark.parametrize(
+    "data, columns, index",
+    [
+        # Test basic reindex and ignoring blank
+        ([["Min", "Max"], [np.nan, ""]], ["A", "C"], ["x", "y"]),
+        # Test non-referenced columns, reversed col names, short index
+        ([["Max", "Min", "Bad-Col"]], ["C", "A", "D"], ["x"]),
+    ],
+)
+def test_tooltip_render_as_title(data, columns, index, styler):
+    ttips = DataFrame(data=data, columns=columns, index=index)
+    # GH 56605
+    result = styler.set_tooltips(ttips, as_title_attribute=True).to_html()
+
+    # test css not added
+    assert "#T_ .pd-t {\n  visibility: hidden;\n" not in result
+
+    # test 'Min' tooltip added as title attribute and css does not exist
+    assert "#T_ #T__row0_col0:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert '#T_ #T__row0_col0 .pd-t::after {\n  content: "Min";\n}' not in result
+    assert 'class="data row0 col0"  title="Min">0</td>' in result
+
+    # test 'Max' tooltip added as title attribute and css does not exist
+    assert "#T_ #T__row0_col2:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert '#T_ #T__row0_col2 .pd-t::after {\n  content: "Max";\n}' not in result
+    assert 'class="data row0 col2"  title="Max">2</td>' in result
+
+    # test Nan, empty string and bad column ignored
+    assert "#T_ #T__row1_col0:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row1_col1:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row0_col1:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row1_col2:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "Bad-Col" not in result
+    assert 'class="data row0 col1" >1</td>' in result
+    assert 'class="data row1 col0" >3</td>' in result
+    assert 'class="data row1 col1" >4</td>' in result
+    assert 'class="data row1 col2" >5</td>' in result
+    assert 'class="data row2 col0" >6</td>' in result
+    assert 'class="data row2 col1" >7</td>' in result
+    assert 'class="data row2 col2" >8</td>' in result
+
+
+def test_tooltip_render_as_title_with_hidden_index_level():
+    df = DataFrame(
+        data=[[0, 1, 2], [3, 4, 5], [6, 7, 8]],
+        columns=["A", "B", "C"],
+        index=MultiIndex.from_arrays(
+            [["x", "y", "z"], [1, 2, 3], ["aa", "bb", "cc"]],
+            names=["alpha", "num", "char"],
+        ),
+    )
+    ttips = DataFrame(
+        # Test basic reindex and ignoring blank, and hide level 2 (num) from index
+        data=[["Min", "Max"], [np.nan, ""]],
+        columns=["A", "C"],
+        index=MultiIndex.from_arrays(
+            [["x", "y"], [1, 2], ["aa", "bb"]], names=["alpha", "num", "char"]
+        ),
+    )
+    styler = Styler(df, uuid_len=0)
+    styler = styler.hide(axis=0, level=-1, names=True)
+    # GH 56605
+    result = styler.set_tooltips(ttips, as_title_attribute=True).to_html()
+
+    # test css not added
+    assert "#T_ .pd-t {\n  visibility: hidden;\n" not in result
+
+    # test 'Min' tooltip added as title attribute and css does not exist
+    assert "#T_ #T__row0_col0:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert '#T_ #T__row0_col0 .pd-t::after {\n  content: "Min";\n}' not in result
+    assert 'class="data row0 col0"  title="Min">0</td>' in result
+
+    # test 'Max' tooltip added as title attribute and css does not exist
+    assert "#T_ #T__row0_col2:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert '#T_ #T__row0_col2 .pd-t::after {\n  content: "Max";\n}' not in result
+    assert 'class="data row0 col2"  title="Max">2</td>' in result
+
+    # test Nan, empty string and bad column ignored
+    assert "#T_ #T__row1_col0:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row1_col1:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row0_col1:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row1_col2:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "Bad-Col" not in result
+    assert 'class="data row0 col1" >1</td>' in result
+    assert 'class="data row1 col0" >3</td>' in result
+    assert 'class="data row1 col1" >4</td>' in result
+    assert 'class="data row1 col2" >5</td>' in result
+    assert 'class="data row2 col0" >6</td>' in result
+    assert 'class="data row2 col1" >7</td>' in result
+    assert 'class="data row2 col2" >8</td>' in result