Repository: pandas-dev/pandas (python)

Issue:
BUG: df.plot() multi-column subplots & title interaction
### Pandas version checks

- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the [latest version](https://pandas.pydata.org/docs/whatsnew/index.html) of pandas.

- [x] I have confirmed this bug exists on the [main branch](https://pandas.pydata.org/docs/dev/getting_started/install.html#installing-the-development-version-of-pandas) of pandas.


### Reproducible Example

```python
import matplotlib.pyplot as plt
import pandas as pd

df = pd.DataFrame([(30, 10, 10), (20, 20, 20), (10, 30, 30)], columns=list('ABC'))
df.plot(subplots= [('A','B')],kind="bar", stacked=True, title=["A&B","C", "Needs another title despite no title here, removing this will error"])

print(df)
# print(pd.show_versions())
plt.show()
```

### Issue Description

df.plot() has a hard coded check to make sure there are the same number of titles when using with the "subplots" parameter, however subplots allows for multiple columns to be represented on one plot, so there are scenarios where the number of plots is less than the check for the number of titles

### Expected Behavior

You shouldn't need to purposefully make empty titles up to the number of columns, the check should be run on the number of subplots

### Installed Versions

<details>
INSTALLED VERSIONS
------------------
commit                : 0691c5cf90477d3503834d983f69350f250a6ff7
python                : 3.11.0
python-bits           : 64
OS                    : Windows
OS-release            : 10
Version               : 10.0.22631
machine               : AMD64
processor             : AMD64 Family 25 Model 33 Stepping 2, AuthenticAMD
byteorder             : little
LC_ALL                : None
LANG                  : en_US.UTF-8
LOCALE                : English_United States.1252

pandas                : 2.2.3
numpy                 : 1.24.2
pytz                  : 2025.1
dateutil              : 2.8.2
pip                   : 25.0
Cython                : None
sphinx                : None
IPython               : 8.12.0
adbc-driver-postgresql: None
adbc-driver-sqlite    : None
bs4                   : 4.13.3
blosc                 : None
bottleneck            : None
dataframe-api-compat  : None
fastparquet           : None
fsspec                : None
html5lib              : None
hypothesis            : None
gcsfs                 : None
jinja2                : 3.1.2
lxml.etree            : 5.3.0
matplotlib            : 3.10.0
numba                 : None
numexpr               : None
odfpy                 : None
openpyxl              : None
pandas_gbq            : None
psycopg2              : None
pymysql               : None
pyarrow               : None
pyreadstat            : None
pytest                : None
python-calamine       : None
pyxlsb                : None
s3fs                  : None
scipy                 : None
sqlalchemy            : 2.0.9
tables                : None
tabulate              : None
xarray                : None
xlrd                  : None
xlsxwriter            : None
zstandard             : None
tzdata                : 2025.1
qtpy                  : None
pyqt5                 : None

</details>


Pull request:
Subplot title count fix + fix for issue introduced in earlier PR 
- [x] closes #61019  
- [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
- [ ] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [x] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.

Adds a check for length of subplots as an alternative to the default title check and produces an alternative error message if number of subplots does not match titles produced.

Additionally, includes fix for issues introduced during PR: #61340 and mentioned in #61018 

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5308c98e96..6514d43209 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -19,7 +19,7 @@ ci:
     skip: [pyright, mypy]
 repos:
 -   repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.11.4
+    rev: v0.11.8
     hooks:
     -   id: ruff
         args: [--exit-non-zero-on-fix]
@@ -95,14 +95,14 @@ repos:
     - id: sphinx-lint
       args: ["--enable", "all", "--disable", "line-too-long"]
 -   repo: https://github.com/pre-commit/mirrors-clang-format
-    rev: v20.1.0
+    rev: v20.1.3
     hooks:
     - id: clang-format
       files: ^pandas/_libs/src|^pandas/_libs/include
       args: [-i]
       types_or: [c, c++]
 -   repo: https://github.com/trim21/pre-commit-mirror-meson
-    rev: v1.7.2
+    rev: v1.8.0
     hooks:
     - id: meson-fmt
       args: ['--inplace']
diff --git a/doc/source/reference/arrays.rst b/doc/source/reference/arrays.rst
index 5be08f163e..d37eebef5c 100644
--- a/doc/source/reference/arrays.rst
+++ b/doc/source/reference/arrays.rst
@@ -664,6 +664,7 @@ Data type introspection
     api.types.is_datetime64_dtype
     api.types.is_datetime64_ns_dtype
     api.types.is_datetime64tz_dtype
+    api.types.is_dtype_equal
     api.types.is_extension_array_dtype
     api.types.is_float_dtype
     api.types.is_int64_dtype
diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst
index 0e779ff470..8695e196c4 100644
--- a/doc/source/whatsnew/v3.0.0.rst
+++ b/doc/source/whatsnew/v3.0.0.rst
@@ -693,6 +693,7 @@ Datetimelike
 - Bug in :meth:`to_datetime` on float array with missing values throwing ``FloatingPointError`` (:issue:`58419`)
 - Bug in :meth:`to_datetime` on float32 df with year, month, day etc. columns leads to precision issues and incorrect result. (:issue:`60506`)
 - Bug in :meth:`to_datetime` reports incorrect index in case of any failure scenario. (:issue:`58298`)
+- Bug in :meth:`to_datetime` with ``format="ISO8601"`` and ``utc=True`` where naive timestamps incorrectly inherited timezone offset from previous timestamps in a series. (:issue:`61389`)
 - Bug in :meth:`to_datetime` wrongly converts when ``arg`` is a ``np.datetime64`` object with unit of ``ps``. (:issue:`60341`)
 - Bug in setting scalar values with mismatched resolution into arrays with non-nanosecond ``datetime64``, ``timedelta64`` or :class:`DatetimeTZDtype` incorrectly truncating those scalars (:issue:`56410`)
 
@@ -800,6 +801,7 @@ Plotting
 - Bug in :meth:`DataFrame.plot.bar` with ``stacked=True`` where labels on stacked bars with zero-height segments were incorrectly positioned at the base instead of the label position of the previous segment (:issue:`59429`)
 - Bug in :meth:`DataFrame.plot.line` raising ``ValueError`` when set both color and a ``dict`` style (:issue:`59461`)
 - Bug in :meth:`DataFrame.plot` that causes a shift to the right when the frequency multiplier is greater than one. (:issue:`57587`)
+- Bug in :meth:`DataFrame.plot` where ``title`` would require extra titles when plotting more than one column per subplot. (:issue:`61019`)
 - Bug in :meth:`Series.plot` preventing a line and bar from being aligned on the same plot (:issue:`61161`)
 - Bug in :meth:`Series.plot` preventing a line and scatter plot from being aligned (:issue:`61005`)
 - Bug in :meth:`Series.plot` with ``kind="pie"`` with :class:`ArrowDtype` (:issue:`59192`)
diff --git a/pandas/_libs/tslibs/strptime.pyx b/pandas/_libs/tslibs/strptime.pyx
index fb89f13285..b443aa7bed 100644
--- a/pandas/_libs/tslibs/strptime.pyx
+++ b/pandas/_libs/tslibs/strptime.pyx
@@ -444,6 +444,9 @@ def array_strptime(
             else:
                 val = str(val)
 
+            out_local = 0
+            out_tzoffset = 0
+
             if fmt == "ISO8601":
                 string_to_dts_succeeded = not string_to_dts(
                     val, &dts, &out_bestunit, &out_local,
diff --git a/pandas/core/dtypes/common.py b/pandas/core/dtypes/common.py
index e92f2363b6..68d99937f7 100644
--- a/pandas/core/dtypes/common.py
+++ b/pandas/core/dtypes/common.py
@@ -655,24 +655,38 @@ def is_dtype_equal(source, target) -> bool:
 
     Parameters
     ----------
-    source : The first dtype to compare
-    target : The second dtype to compare
+    source : type or str
+        The first dtype to compare.
+    target : type or str
+        The second dtype to compare.
 
     Returns
     -------
     boolean
         Whether or not the two dtypes are equal.
 
+    See Also
+    --------
+    api.types.is_categorical_dtype : Check whether the provided array or dtype
+                                            is of the Categorical dtype.
+    api.types.is_string_dtype : Check whether the provided array or dtype
+                                       is of the string dtype.
+    api.types.is_object_dtype : Check whether an array-like or dtype is of the
+                                       object dtype.
+
     Examples
     --------
+    >>> from pandas.api.types import is_dtype_equal
     >>> is_dtype_equal(int, float)
     False
     >>> is_dtype_equal("int", int)
     True
     >>> is_dtype_equal(object, "category")
     False
+    >>> from pandas.core.dtypes.dtypes import CategoricalDtype
     >>> is_dtype_equal(CategoricalDtype(), "category")
     True
+    >>> from pandas.core.dtypes.dtypes import DatetimeTZDtype
     >>> is_dtype_equal(DatetimeTZDtype(tz="UTC"), "datetime64")
     False
     """
diff --git a/pandas/plotting/_matplotlib/core.py b/pandas/plotting/_matplotlib/core.py
index 0e06cb10d2..1c7e1ab57b 100644
--- a/pandas/plotting/_matplotlib/core.py
+++ b/pandas/plotting/_matplotlib/core.py
@@ -802,7 +802,13 @@ class MPLPlot(ABC):
         if self.title:
             if self.subplots:
                 if is_list_like(self.title):
-                    if len(self.title) != self.nseries:
+                    if not isinstance(self.subplots, bool):
+                        if len(self.subplots) != len(self.title):
+                            raise ValueError(
+                                f"The number of titles ({len(self.title)}) must equal "
+                                f"the number of subplots ({len(self.subplots)})."
+                            )
+                    elif len(self.title) != self.nseries:
                         raise ValueError(
                             "The length of `title` must equal the number "
                             "of columns if using `title` of type `list` "
@@ -1934,13 +1940,14 @@ class BarPlot(MPLPlot):
 
         self.subplots: list[Any]
 
-        if bool(self.subplots) and self.stacked:
-            for i, sub_plot in enumerate(self.subplots):
-                if len(sub_plot) <= 1:
-                    continue
-                for plot in sub_plot:
-                    _stacked_subplots_ind[int(plot)] = i
-                _stacked_subplots_offsets.append([0, 0])
+        if not isinstance(self.subplots, bool):
+            if bool(self.subplots) and self.stacked:
+                for i, sub_plot in enumerate(self.subplots):
+                    if len(sub_plot) <= 1:
+                        continue
+                    for plot in sub_plot:
+                        _stacked_subplots_ind[int(plot)] = i
+                    _stacked_subplots_offsets.append([0, 0])
 
         for i, (label, y) in enumerate(self._iter_data(data=data)):
             ax = self._get_ax(i)


Test patch:
diff --git a/pandas/tests/plotting/test_misc.py b/pandas/tests/plotting/test_misc.py
index f4a0f1e792..d3e1d7f603 100644
--- a/pandas/tests/plotting/test_misc.py
+++ b/pandas/tests/plotting/test_misc.py
@@ -31,6 +31,8 @@ mpl = pytest.importorskip("matplotlib")
 plt = pytest.importorskip("matplotlib.pyplot")
 cm = pytest.importorskip("matplotlib.cm")
 
+import re
+
 from pandas.plotting._matplotlib.style import get_standard_colors
 
 
@@ -727,7 +729,11 @@ def _df_bar_subplot_checker(df_bar_data, df_bar_df, subplot_data_df, subplot_col
         ].reset_index()
         for i in range(len(subplot_columns))
     ]
-    expected_total_height = df_bar_df.loc[:, subplot_columns].sum(axis=1)
+
+    if len(subplot_columns) == 1:
+        expected_total_height = df_bar_df.loc[:, subplot_columns[0]]
+    else:
+        expected_total_height = df_bar_df.loc[:, subplot_columns].sum(axis=1)
 
     for i in range(len(subplot_columns)):
         sliced_df = subplot_sliced_by_source[i]
@@ -743,7 +749,6 @@ def _df_bar_subplot_checker(df_bar_data, df_bar_df, subplot_data_df, subplot_col
             tm.assert_series_equal(
                 height_iter, expected_total_height, check_names=False, check_dtype=False
             )
-
         else:
             # Checks each preceding bar ends where the next one starts
             next_start_coord = subplot_sliced_by_source[i + 1]["y_coord"]
@@ -816,3 +821,44 @@ def test_bar_2_subplots_1_triple_stacked(df_bar_data, df_bar_df, subplot_divisio
         _df_bar_subplot_checker(
             df_bar_data, df_bar_df, subplot_data_df_list[i], subplot_division[i]
         )
+
+
+def test_bar_subplots_stacking_bool(df_bar_data, df_bar_df):
+    subplot_division = [("A"), ("B"), ("C"), ("D")]
+    ax = df_bar_df.plot(subplots=True, kind="bar", stacked=True)
+    subplot_data_df_list = _df_bar_xyheight_from_ax_helper(
+        df_bar_data, ax, subplot_division
+    )
+    for i in range(len(subplot_data_df_list)):
+        _df_bar_subplot_checker(
+            df_bar_data, df_bar_df, subplot_data_df_list[i], subplot_division[i]
+        )
+
+
+def test_plot_bar_label_count_default():
+    df = DataFrame(
+        [(30, 10, 10, 10), (20, 20, 20, 20), (10, 30, 30, 10)], columns=list("ABCD")
+    )
+    df.plot(subplots=True, kind="bar", title=["A", "B", "C", "D"])
+
+
+def test_plot_bar_label_count_expected_fail():
+    df = DataFrame(
+        [(30, 10, 10, 10), (20, 20, 20, 20), (10, 30, 30, 10)], columns=list("ABCD")
+    )
+    error_regex = re.escape(
+        "The number of titles (4) must equal the number of subplots (3)."
+    )
+    with pytest.raises(ValueError, match=error_regex):
+        df.plot(
+            subplots=[("A", "B")],
+            kind="bar",
+            title=["A&B", "C", "D", "Extra Title"],
+        )
+
+
+def test_plot_bar_label_count_expected_success():
+    df = DataFrame(
+        [(30, 10, 10, 10), (20, 20, 20, 20), (10, 30, 30, 10)], columns=list("ABCD")
+    )
+    df.plot(subplots=[("A", "B", "D")], kind="bar", title=["A&B&D", "C"])
diff --git a/pandas/tests/tools/test_to_datetime.py b/pandas/tests/tools/test_to_datetime.py
index 616ae36c98..b02fab70fb 100644
--- a/pandas/tests/tools/test_to_datetime.py
+++ b/pandas/tests/tools/test_to_datetime.py
@@ -3514,6 +3514,54 @@ def test_to_datetime_mixed_not_necessarily_iso8601_coerce():
     tm.assert_index_equal(result, DatetimeIndex(["2020-01-01 00:00:00", NaT]))
 
 
+def test_to_datetime_iso8601_utc_single_naive():
+    # GH#61389
+    result = to_datetime("2023-10-15T14:30:00", utc=True, format="ISO8601")
+    expected = Timestamp("2023-10-15 14:30:00+00:00")
+    assert result == expected
+
+
+def test_to_datetime_iso8601_utc_mixed_negative_offset():
+    # GH#61389
+    data = ["2023-10-15T10:30:00-12:00", "2023-10-15T14:30:00"]
+    result = to_datetime(data, utc=True, format="ISO8601")
+
+    expected = DatetimeIndex(
+        [Timestamp("2023-10-15 22:30:00+00:00"), Timestamp("2023-10-15 14:30:00+00:00")]
+    )
+    tm.assert_index_equal(result, expected)
+
+
+def test_to_datetime_iso8601_utc_mixed_positive_offset():
+    # GH#61389
+    data = ["2023-10-15T10:30:00+08:00", "2023-10-15T14:30:00"]
+    result = to_datetime(data, utc=True, format="ISO8601")
+
+    expected = DatetimeIndex(
+        [Timestamp("2023-10-15 02:30:00+00:00"), Timestamp("2023-10-15 14:30:00+00:00")]
+    )
+    tm.assert_index_equal(result, expected)
+
+
+def test_to_datetime_iso8601_utc_mixed_both_offsets():
+    # GH#61389
+    data = [
+        "2023-10-15T10:30:00+08:00",
+        "2023-10-15T12:30:00-05:00",
+        "2023-10-15T14:30:00",
+    ]
+    result = to_datetime(data, utc=True, format="ISO8601")
+
+    expected = DatetimeIndex(
+        [
+            Timestamp("2023-10-15 02:30:00+00:00"),
+            Timestamp("2023-10-15 17:30:00+00:00"),
+            Timestamp("2023-10-15 14:30:00+00:00"),
+        ]
+    )
+    tm.assert_index_equal(result, expected)
+
+
 def test_unknown_tz_raises():
     # GH#18702, GH#51476
     dtstr = "2014 Jan 9 05:15 FAKE"